feat: add template bootstrapping for project create
Some checks failed
continuous-integration/drone/push Build encountered an error

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.
This commit is contained in:
2026-03-20 13:07:36 +01:00
parent ad0f29826b
commit bebb55e873
3 changed files with 122 additions and 0 deletions

View File

@@ -42,6 +42,10 @@ pub struct ProjectCreateCommand {
#[arg()] #[arg()]
name: Option<String>, name: Option<String>,
/// Bootstrap from a template in the templates directory
#[arg(long = "template", short = 't')]
template: Option<String>,
/// Skip cache when fetching repositories /// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")] #[arg(long = "no-cache", default_value = "false")]
no_cache: bool, no_cache: bool,
@@ -73,6 +77,68 @@ pub struct ProjectDeleteCommand {
force: bool, 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<Vec<TemplateEntry>> {
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 { fn get_projects_dir(app: &'static App) -> PathBuf {
if let Some(ref project_settings) = app.config.settings.project { if let Some(ref project_settings) = app.config.settings.project {
if let Some(ref dir) = project_settings.directory { 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!( eprintln!(
"project '{}' created at {} with {} repositories", "project '{}' created at {} with {} repositories",
dir_name, dir_name,

View File

@@ -41,6 +41,11 @@ pub struct ProjectSettings {
/// Directory where projects are stored. /// Directory where projects are stored.
/// Default: "~/.gitnow/projects" /// Default: "~/.gitnow/projects"
pub directory: Option<String>, pub directory: Option<String>,
/// Directory containing project templates.
/// Each subdirectory is a template whose files are copied into new projects.
/// Default: "~/.gitnow/templates"
pub templates_directory: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]

17
templates/default/SPEC.md Normal file
View File

@@ -0,0 +1,17 @@
# Project Spec
## Overview
<!-- Describe the purpose and goals of this project -->
## Repositories
<!-- List the repositories included and their roles -->
## Architecture
<!-- Describe how the repositories relate to each other -->
## Getting Started
<!-- Steps to get up and running after cloning -->