From bebb55e873d6cfcc343af0d597a35f705863d142 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Fri, 20 Mar 2026 13:07:36 +0100 Subject: [PATCH] feat: add template bootstrapping for project create Adds --template/-t flag to `gitnow project create` that copies files from a template directory into new projects. Templates are discovered from ~/.gitnow/templates/ (configurable via settings.project.templates_directory). Includes a default template with a SPEC.md scaffold. --- crates/gitnow/src/commands/project.rs | 100 ++++++++++++++++++++++++++ crates/gitnow/src/config.rs | 5 ++ templates/default/SPEC.md | 17 +++++ 3 files changed, 122 insertions(+) create mode 100644 templates/default/SPEC.md diff --git a/crates/gitnow/src/commands/project.rs b/crates/gitnow/src/commands/project.rs index 0e3d266..9612b06 100644 --- a/crates/gitnow/src/commands/project.rs +++ b/crates/gitnow/src/commands/project.rs @@ -42,6 +42,10 @@ pub struct ProjectCreateCommand { #[arg()] name: Option, + /// Bootstrap from a template in the templates directory + #[arg(long = "template", short = 't')] + template: Option, + /// Skip cache when fetching repositories #[arg(long = "no-cache", default_value = "false")] no_cache: bool, @@ -73,6 +77,68 @@ pub struct ProjectDeleteCommand { force: bool, } +fn get_templates_dir(app: &'static App) -> PathBuf { + if let Some(ref project_settings) = app.config.settings.project { + if let Some(ref dir) = project_settings.templates_directory { + let path = PathBuf::from(dir); + if let Ok(stripped) = path.strip_prefix("~") { + let home = dirs::home_dir().unwrap_or_default(); + return home.join(stripped); + } + return path; + } + } + + let home = dirs::home_dir().unwrap_or_default(); + home.join(".gitnow").join("templates") +} + +#[derive(Clone)] +struct TemplateEntry { + name: String, + path: PathBuf, +} + +impl Searchable for TemplateEntry { + fn display_label(&self) -> String { + self.name.clone() + } +} + +fn list_templates(templates_dir: &PathBuf) -> anyhow::Result> { + if !templates_dir.exists() { + return Ok(Vec::new()); + } + + let mut templates = Vec::new(); + for entry in std::fs::read_dir(templates_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + templates.push(TemplateEntry { + name, + path: entry.path(), + }); + } + } + templates.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(templates) +} + +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dest_path = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), &dest_path)?; + } + } + Ok(()) +} + fn get_projects_dir(app: &'static App) -> PathBuf { if let Some(ref project_settings) = app.config.settings.project { if let Some(ref dir) = project_settings.directory { @@ -311,6 +377,40 @@ impl ProjectCreateCommand { } } + // Step 6: Apply template if requested + let templates_dir = get_templates_dir(app); + let template = match self.template.take() { + Some(name) => { + let templates = list_templates(&templates_dir)?; + Some( + templates + .into_iter() + .find(|t| t.name == name) + .ok_or_else(|| { + anyhow::anyhow!( + "template '{}' not found in {}", + name, + templates_dir.display() + ) + })?, + ) + } + None => { + let templates = list_templates(&templates_dir)?; + if !templates.is_empty() { + eprintln!("Select a project template (Esc to skip):"); + app.interactive().interactive_search_items(&templates)? + } else { + None + } + } + }; + + if let Some(template) = template { + eprintln!(" applying template '{}'...", template.name); + copy_dir_recursive(&template.path, &project_path)?; + } + eprintln!( "project '{}' created at {} with {} repositories", dir_name, diff --git a/crates/gitnow/src/config.rs b/crates/gitnow/src/config.rs index b0088b3..6d89099 100644 --- a/crates/gitnow/src/config.rs +++ b/crates/gitnow/src/config.rs @@ -41,6 +41,11 @@ pub struct ProjectSettings { /// Directory where projects are stored. /// Default: "~/.gitnow/projects" pub directory: Option, + + /// Directory containing project templates. + /// Each subdirectory is a template whose files are copied into new projects. + /// Default: "~/.gitnow/templates" + pub templates_directory: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/templates/default/SPEC.md b/templates/default/SPEC.md new file mode 100644 index 0000000..f5196b8 --- /dev/null +++ b/templates/default/SPEC.md @@ -0,0 +1,17 @@ +# Project Spec + +## Overview + + + +## Repositories + + + +## Architecture + + + +## Getting Started + +