use anyhow::{Context, Result};
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf};
#[derive(Subcommand)]
pub enum BuildCommand {
List,
Apply {
name: String,
#[arg(long)]
dry_run: bool,
},
Save {
name: String,
#[arg(short, long)]
description: Option<String>,
},
Delete {
name: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Build {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub gitignore: GitignoreConfig,
#[serde(default)]
pub gitattributes: GitattributesConfig,
#[serde(default)]
pub config: ConfigBuild,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct HooksConfig {
#[serde(default)]
pub builtins: Vec<String>,
#[serde(default)]
pub custom: Vec<CustomHook>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CustomHook {
pub hook: String,
pub command: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct GitignoreConfig {
#[serde(default)]
pub templates: Vec<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct GitattributesConfig {
#[serde(default)]
pub presets: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigBuild {
#[serde(default)]
pub keys: Vec<String>,
#[serde(default = "default_scope")]
pub scope: String,
}
impl Default for ConfigBuild {
fn default() -> Self {
Self {
keys: Vec::new(),
scope: default_scope(),
}
}
}
fn default_scope() -> String {
"local".to_string()
}
pub(crate) fn builds_dir() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("Neither HOME nor USERPROFILE environment variable is set")?;
Ok(PathBuf::from(home).join(".gitkit").join("builds"))
}
fn build_path(name: &str) -> Result<PathBuf> {
anyhow::ensure!(
!name.is_empty() && !name.contains(['/', '\\']) && name != "." && name != "..",
"Invalid build name '{name}' — use a simple name without path separators"
);
Ok(builds_dir()?.join(format!("{name}.toml")))
}
pub fn run(cmd: BuildCommand) -> Result<()> {
match cmd {
BuildCommand::List => list(),
BuildCommand::Apply { name, dry_run } => apply(&name, dry_run),
BuildCommand::Save { name, description } => save(&name, description.as_deref()),
BuildCommand::Delete { name } => delete(&name),
}
}
fn list() -> Result<()> {
let dir = builds_dir()?;
if !dir.exists() {
println!("No builds saved.");
return Ok(());
}
let builds: Vec<_> = fs::read_dir(&dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
.collect();
if builds.is_empty() {
println!("No builds saved.");
return Ok(());
}
println!("Saved builds:\n");
for entry in builds {
let path = entry.path();
let name = path.file_stem().unwrap().to_string_lossy();
let content = fs::read_to_string(&path).unwrap_or_default();
let build: Build = match toml::from_str(&content) {
Ok(b) => b,
Err(_) => continue,
};
let desc = if build.description.is_empty() {
""
} else {
&format!(" — {}", build.description)
};
println!(" {name}{desc}");
if !build.hooks.builtins.is_empty() || !build.hooks.custom.is_empty() {
let hooks: Vec<&str> = build
.hooks
.builtins
.iter()
.map(|s| s.as_str())
.chain(build.hooks.custom.iter().map(|c| c.hook.as_str()))
.collect();
println!(" hooks: {}", hooks.join(", "));
}
if !build.gitignore.templates.is_empty() {
println!(" gitignore: {}", build.gitignore.templates.join(", "));
}
if !build.gitattributes.presets.is_empty() {
println!(
" gitattributes: {}",
build.gitattributes.presets.join(", ")
);
}
if !build.config.keys.is_empty() {
println!(
" config ({}): {}",
build.config.scope,
build.config.keys.join(", ")
);
}
println!();
}
Ok(())
}
fn apply(name: &str, dry_run: bool) -> Result<()> {
let path = build_path(name)?;
anyhow::ensure!(path.exists(), "Build '{name}' not found");
let content = fs::read_to_string(&path).context("Failed to read build file")?;
let build: Build = toml::from_str(&content).context("Failed to parse build file")?;
if dry_run {
println!("[dry-run] Would apply build '{name}':");
if !build.hooks.builtins.is_empty() {
println!(" hooks: {}", build.hooks.builtins.join(", "));
}
for custom in &build.hooks.custom {
println!(" custom hook: {} → {}", custom.hook, custom.command);
}
if !build.gitignore.templates.is_empty() {
println!(" gitignore: {}", build.gitignore.templates.join(", "));
}
if !build.gitattributes.presets.is_empty() {
println!(
" gitattributes: {}",
build.gitattributes.presets.join(", ")
);
}
if !build.config.keys.is_empty() {
println!(
" config ({}): {}",
build.config.scope,
build.config.keys.join(", ")
);
}
return Ok(());
}
apply_build(&build)?;
println!(" ✓ Build '{name}' applied");
Ok(())
}
pub(crate) fn apply_build(build: &Build) -> Result<()> {
let cargo_available = std::process::Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
for name in &build.hooks.builtins {
crate::hooks::install_builtin(name, false)?;
println!(" ◇ hook '{name}' installed ✓");
}
for custom in &build.hooks.custom {
crate::hooks::install_custom(&custom.hook, &custom.command, false)?;
println!(" ◇ custom hook '{}' installed ✓", custom.hook);
}
if !build.gitignore.templates.is_empty() {
let joined = build.gitignore.templates.join(",");
crate::ignore::add_templates(&joined, false)?;
println!(" ◇ .gitignore updated ✓");
}
if !build.gitattributes.presets.is_empty() {
let presets: Vec<&str> = build
.gitattributes
.presets
.iter()
.map(|s| s.as_str())
.collect();
crate::attributes::apply_presets(&presets)?;
println!(" ◇ .gitattributes applied ✓");
}
if !build.config.keys.is_empty() {
let scope = if build.config.scope == "global" {
crate::config::ConfigScope::Global
} else {
crate::config::ConfigScope::Local
};
let keys: Vec<&str> = build.config.keys.iter().map(|s| s.as_str()).collect();
crate::config::apply_config_keys(&keys, cargo_available, scope)?;
println!(" ◇ git config applied ✓");
}
Ok(())
}
pub(crate) fn save(name: &str, description: Option<&str>) -> Result<()> {
let path = build_path(name)?;
if path.exists() {
anyhow::bail!("Build '{name}' already exists. Delete it first or choose another name.");
}
let build = capture_current_config(name, description)?;
let dir = builds_dir()?;
fs::create_dir_all(&dir).context("Failed to create builds directory")?;
let content = toml::to_string_pretty(&build).context("Failed to serialize build")?;
fs::write(&path, content).context("Failed to write build file")?;
println!(" ✓ Build '{name}' saved to {}", path.display());
Ok(())
}
pub(crate) fn capture_current_config(name: &str, description: Option<&str>) -> Result<Build> {
let root = crate::utils::find_repo_root()?;
let mut builtins = Vec::new();
let mut custom = Vec::new();
let hooks_dir = root.join(".git").join("hooks");
if hooks_dir.exists() {
if let Ok(entries) = fs::read_dir(&hooks_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let hook_name = entry.file_name().to_string_lossy().to_string();
if hook_name.ends_with(".bak") || hook_name.ends_with(".sample") {
continue;
}
let content = fs::read_to_string(&path).unwrap_or_default();
if let Some(b) = crate::hooks::detect_builtin(&hook_name, &content) {
builtins.push(b.name.to_string());
} else if crate::hooks::valid_hook_names().contains(&hook_name.as_str()) {
if let Some(command) = extract_custom_command(&content) {
custom.push(CustomHook {
hook: hook_name,
command,
});
}
}
}
}
}
let gitignore_path = root.join(".gitignore");
let templates = if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path)?;
detect_gitignore_templates(&content)
} else {
Vec::new()
};
let gitattributes_path = root.join(".gitattributes");
let presets = if gitattributes_path.exists() {
let content = fs::read_to_string(&gitattributes_path)?;
detect_gitattributes_presets(&content)
} else {
Vec::new()
};
let mut config_keys = Vec::new();
for option in crate::config::CONFIG_OPTIONS {
if option.key == "core.pager" {
continue;
}
if let Some(expected) = option.value {
if crate::utils::git_config_get(option.key, "--local").as_deref() == Some(expected) {
config_keys.push(option.key.to_string());
}
}
}
Ok(Build {
name: name.to_string(),
description: description.unwrap_or("").to_string(),
hooks: HooksConfig { builtins, custom },
gitignore: GitignoreConfig { templates },
gitattributes: GitattributesConfig { presets },
config: ConfigBuild {
keys: config_keys,
scope: "local".to_string(),
},
})
}
fn extract_custom_command(content: &str) -> Option<String> {
let lines: Vec<&str> = content
.lines()
.map(str::trim_end)
.filter(|l| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#') && t != "set -e"
})
.collect();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn detect_gitignore_templates(content: &str) -> Vec<String> {
let mut templates = Vec::new();
let patterns = [
("rust", "target/"),
("node", "node_modules/"),
("python", "__pycache__/"),
("vscode", ".vscode/"),
("agentic", ".kiro/"),
];
for (name, pattern) in &patterns {
if content.contains(pattern) {
templates.push(name.to_string());
}
}
templates
}
fn detect_gitattributes_presets(content: &str) -> Vec<String> {
let mut presets = Vec::new();
if content.contains("eol=lf") {
presets.push("line-endings".to_string());
}
if content.contains("binary") {
presets.push("binary-files".to_string());
}
presets
}
fn delete(name: &str) -> Result<()> {
let path = build_path(name)?;
anyhow::ensure!(path.exists(), "Build '{name}' not found");
fs::remove_file(&path).context("Failed to delete build file")?;
println!(" ✓ Build '{name}' deleted");
Ok(())
}
pub(crate) fn list_build_names() -> Vec<String> {
builds_dir()
.ok()
.and_then(|dir| {
if !dir.exists() {
return None;
}
Some(
fs::read_dir(&dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
.map(|e| e.path().file_stem().unwrap().to_string_lossy().to_string())
.collect(),
)
})
.unwrap_or_default()
}
pub(crate) fn load_build(name: &str) -> Result<Build> {
let path = build_path(name)?;
anyhow::ensure!(path.exists(), "Build '{name}' not found");
let content = fs::read_to_string(&path).context("Failed to read build file")?;
toml::from_str(&content).context("Failed to parse build file")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_serializes_to_toml() {
let build = Build {
name: "test".to_string(),
description: "Test build".to_string(),
hooks: HooksConfig {
builtins: vec!["conventional-commits".to_string()],
custom: Vec::new(),
},
gitignore: GitignoreConfig {
templates: vec!["rust".to_string()],
},
gitattributes: GitattributesConfig {
presets: vec!["line-endings".to_string()],
},
config: ConfigBuild {
keys: vec!["push.autoSetupRemote".to_string()],
scope: "local".to_string(),
},
};
let toml_str = toml::to_string_pretty(&build).unwrap();
assert!(toml_str.contains("conventional-commits"));
assert!(toml_str.contains("rust"));
let parsed: Build = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(parsed.hooks.builtins, vec!["conventional-commits"]);
}
#[test]
fn build_deserializes_with_defaults() {
let toml_str = r#"
name = "minimal"
description = ""
"#;
let build: Build = toml::from_str(toml_str).unwrap();
assert_eq!(build.name, "minimal");
assert!(build.hooks.builtins.is_empty());
assert!(build.gitignore.templates.is_empty());
assert_eq!(build.config.scope, "local");
}
#[test]
fn detect_gitignore_templates_finds_rust() {
let content = "# Rust\ntarget/\n*.pdb\n";
let templates = detect_gitignore_templates(content);
assert!(templates.contains(&"rust".to_string()));
}
#[test]
fn detect_gitattributes_presets_finds_line_endings() {
let content = "* text=auto eol=lf\n";
let presets = detect_gitattributes_presets(content);
assert!(presets.contains(&"line-endings".to_string()));
}
#[test]
fn extract_custom_command_recovers_command() {
let script = "#!/bin/sh\nset -e\ncargo test\n";
assert_eq!(
extract_custom_command(script).as_deref(),
Some("cargo test")
);
}
#[test]
fn extract_custom_command_returns_none_for_empty_script() {
assert!(extract_custom_command("#!/bin/sh\nset -e\n").is_none());
}
#[test]
fn build_path_rejects_invalid_names() {
assert!(build_path("").is_err());
assert!(build_path("../evil").is_err());
assert!(build_path("a/b").is_err());
assert!(build_path("ok-name").is_ok());
}
}