diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76c2f2..522d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: jobs: rust-ci: diff --git a/Cargo.toml b/Cargo.toml index bb96392..980011c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" ureq = "2" [dev-dependencies] diff --git a/README.md b/README.md index 0a43c35..b892dcd 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ Set up a git repo the way you actually work — one guided flow for hooks, `.git ## Features - **🪄 Guided repo setup** — Configure hooks, `.gitignore`, `.gitattributes`, and git config in one interactive flow. +- **📊 Status overview** — See what's currently configured with `gitkit status`. - **🔁 Clone and bootstrap** — Clone a repo and drop straight into the setup wizard. - **🧰 Hook management** — Install, list, show, or remove built-in hooks, or wire up your own command. - **🧩 Ignore and attribute presets** — Browse built-in and gitignore.io templates, then apply line-ending or binary presets. -- **⚙️ Curated git config** — Apply practical presets like auto-upstream, autocorrect, histogram diffs, zdiff3, rerere, and delta pager setup. +- **⚙️ Curated git config** — Apply practical presets with `--global` or `--local` scope, with idempotency detection. +- **💾 Save & reuse builds** — Save configurations and apply them to any project with one command. - **📦 Single binary** — No Node.js, no Python, no extra runtime. --- @@ -84,18 +86,24 @@ Remove-Item "$env:LOCALAPPDATA\gitkit\gitkit.exe" -Force ## Quick Start -**Clone and configure a repo in one command:** +**Run the wizard (no arguments needed):** ```bash -gitkit clone https://github.com/user/repo +gitkit ``` -Or configure an existing repo: +Or explicitly: ```bash gitkit init ``` +**Clone and configure a repo in one command:** + +```bash +gitkit clone https://github.com/user/repo +``` + Or use commands directly: ```bash @@ -107,21 +115,58 @@ gitkit config apply defaults --- +## `gitkit status` + +Show what's currently configured in your repo and globally. + +```bash +gitkit status +``` + +**Output example:** + +``` +Hooks: + ✓ conventional-commits (commit-msg) + ✓ custom: pre-push → "cargo test" + +.gitignore: + ✓ 14 patterns + +.gitattributes: + ✓ line-endings (eol=lf) + +Git config (local): + (none) + +Git config (global): + ✓ push.autoSetupRemote = true + ✓ help.autocorrect = prompt + ✓ diff.algorithm = histogram +``` + +--- + ## `gitkit init` -Interactive wizard that guides you through configuring a repo step by step. +Interactive wizard that guides you through configuring a repo step by step. Shows what's already configured and allows removal. -- Hooks — built-ins pre-selected, or add a custom command +- Hooks — shows installed hooks, pre-selects them, allows removal - `.gitignore` — filterable search across all gitignore.io templates + built-ins - `.gitattributes` — line endings and binary file presets -- Git config — 6 individual options, recommended ones pre-selected +- Git config — shows current values, allows removal +- Custom hooks — interactive picker for hook type selection -Automatically initializes a git repository if one doesn't exist: +Run without arguments or explicitly: ```bash +gitkit +# or gitkit init ``` +Automatically initializes a git repository if one doesn't exist. + --- ## `gitkit clone` @@ -194,6 +239,50 @@ The wizard runs automatically after cloning, allowing you to configure hooks, `. | `gitkit config apply defaults` | `push.autoSetupRemote`, `help.autocorrect`, `diff.algorithm` | | `gitkit config apply advanced` | `merge.conflictstyle zdiff3`, `rerere.enabled` | | `gitkit config apply delta` | `core.pager delta` (requires `cargo`) | +| `gitkit config show` | Show current git config values | + +**Scope options:** + +- `--global` — Apply to global git config (all repos) +- `--local` — Apply to local repo config only +- Default: `--local` if in a repo, `--global` otherwise + +**Idempotency:** + +Configs already set with the same value show `(already set)` and are skipped. + +```bash +$ gitkit config apply defaults --global +✓ push.autoSetupRemote = true (already set) +✓ help.autocorrect = prompt (already set) +✓ diff.algorithm = histogram (already set) + +All configs already applied. +``` + +### Build + +Save and reuse configurations across projects. + +| Command | Description | +|---|---| +| `gitkit build list` | List saved builds | +| `gitkit build save ` | Save current repo config as a build | +| `gitkit build apply ` | Apply a saved build | +| `gitkit build delete ` | Delete a saved build | + +**Example:** + +```bash +# Save current configuration +gitkit build save rust-dev --description "Rust development setup" + +# Apply to another project +cd /path/to/other/project +gitkit build apply rust-dev +``` + +Builds are saved to `~/.gitkit/builds/` as TOML files. --- diff --git a/src/builds/mod.rs b/src/builds/mod.rs new file mode 100644 index 0000000..9590b70 --- /dev/null +++ b/src/builds/mod.rs @@ -0,0 +1,520 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use serde::{Deserialize, Serialize}; +use std::{fs, path::PathBuf}; + +#[derive(Subcommand)] +pub enum BuildCommand { + /// List saved builds + List, + /// Apply a saved build + Apply { + /// Build name + name: String, + #[arg(long)] + dry_run: bool, + }, + /// Save current configuration as a build + Save { + /// Build name + name: String, + /// Optional description + #[arg(short, long)] + description: Option, + }, + /// Delete a saved build + Delete { + /// Build name + 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, + #[serde(default)] + pub custom: Vec, +} + +#[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, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct GitattributesConfig { + #[serde(default)] + pub presets: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ConfigBuild { + #[serde(default)] + pub keys: Vec, + #[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 { + 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 { + 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 { + 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(), + }, + }) +} + +/// Recovers the command from a custom hook script (shebang + `set -e` + command). +fn extract_custom_command(content: &str) -> Option { + 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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 41301b3..feadd63 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::{Subcommand, ValueEnum}; use std::process::Command; -use crate::utils::confirm; +use crate::utils::{confirm, find_repo_root}; #[derive(Subcommand)] pub enum ConfigCommand { @@ -13,7 +13,13 @@ pub enum ConfigCommand { yes: bool, #[arg(long)] dry_run: bool, + #[arg(long, conflicts_with = "local")] + global: bool, + #[arg(long, conflicts_with = "global")] + local: bool, }, + /// Show current git config values + Show, } #[derive(ValueEnum, Clone)] @@ -27,15 +33,90 @@ pub enum Preset { } pub fn run(cmd: ConfigCommand) -> Result<()> { - let ConfigCommand::Apply { - preset, - yes, - dry_run, - } = cmd; - match preset { - Preset::Defaults => apply_defaults(dry_run), - Preset::Advanced => apply_advanced(dry_run), - Preset::Delta => apply_delta(yes, dry_run), + match cmd { + ConfigCommand::Apply { + preset, + yes, + dry_run, + global, + local, + } => { + let scope = determine_scope(global, local); + match preset { + Preset::Defaults => apply_defaults(dry_run, scope), + Preset::Advanced => apply_advanced(dry_run, scope), + Preset::Delta => apply_delta(yes, dry_run, scope), + } + } + ConfigCommand::Show => show_config(), + } +} + +#[derive(Clone, Copy)] +pub(crate) enum ConfigScope { + Global, + Local, +} + +fn determine_scope(global: bool, local: bool) -> ConfigScope { + if global { + ConfigScope::Global + } else if local || find_repo_root().is_ok() { + ConfigScope::Local + } else { + ConfigScope::Global + } +} + +fn scope_flag(scope: ConfigScope) -> &'static str { + match scope { + ConfigScope::Global => "--global", + ConfigScope::Local => "--local", + } +} + +fn show_config() -> Result<()> { + println!("Git config (global):"); + show_scope_config("--global"); + println!(); + println!("Git config (local):"); + show_scope_config("--local"); + Ok(()) +} + +fn show_scope_config(scope: &str) { + let configs = [ + "push.autoSetupRemote", + "help.autocorrect", + "diff.algorithm", + "merge.conflictstyle", + "rerere.enabled", + "core.pager", + ]; + + let mut any = false; + for key in &configs { + if let Some(value) = git_config_get(key, scope) { + println!(" {key} = {value}"); + any = true; + } + } + + if !any { + println!(" (none)"); + } +} + +fn git_config_get(key: &str, scope: &str) -> Option { + let output = Command::new("git") + .args(["config", scope, "--get", key]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None } } @@ -87,10 +168,12 @@ pub(crate) const CONFIG_OPTIONS: &[ConfigOption] = &[ ]; /// Apply selected config option keys. Used by the interactive wizard. -pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result<()> { +pub(crate) fn apply_config_keys( + keys: &[&str], + cargo_available: bool, + scope: ConfigScope, +) -> Result<()> { for key in keys { - // Find the matching option to reuse its value from CONFIG_OPTIONS context, - // then dispatch to the appropriate setter. match *key { "core.pager" => { anyhow::ensure!( @@ -101,17 +184,16 @@ pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result< install_delta()?; } for (k, v) in DELTA_CONFIGS { - git_config_set(k, v)?; + git_config_set(k, v, scope)?; } } _ => { - // All non-delta options map directly from CONFIG_OPTIONS value let value = CONFIG_OPTIONS .iter() .find(|o| o.key == *key) .and_then(|o| o.value) .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}"))?; - git_config_set(key, value)?; + git_config_set(key, value, scope)?; } } } @@ -138,18 +220,18 @@ const DELTA_CONFIGS: GitConfigs = &[ ("delta.side-by-side", "true"), ]; -fn apply_defaults(dry_run: bool) -> Result<()> { - apply_configs(DEFAULTS, dry_run) +fn apply_defaults(dry_run: bool, scope: ConfigScope) -> Result<()> { + apply_configs(DEFAULTS, dry_run, scope) } -fn apply_advanced(dry_run: bool) -> Result<()> { +fn apply_advanced(dry_run: bool, scope: ConfigScope) -> Result<()> { println!( "Warning: merge.conflictstyle=zdiff3 may cause issues with GitHub Desktop and GUI merge tools." ); - apply_configs(ADVANCED, dry_run) + apply_configs(ADVANCED, dry_run, scope) } -fn apply_delta(yes: bool, dry_run: bool) -> Result<()> { +fn apply_delta(yes: bool, dry_run: bool, scope: ConfigScope) -> Result<()> { if !delta_installed() { if !confirm( "git-delta is not installed. Install via `cargo install git-delta`?", @@ -166,29 +248,54 @@ fn apply_delta(yes: bool, dry_run: bool) -> Result<()> { } println!( "Note: delta.side-by-side=true may look wrong in narrow terminals. \ - Disable with: git config --global delta.side-by-side false" + Disable with: git config {} delta.side-by-side false", + scope_flag(scope) ); - apply_configs(DELTA_CONFIGS, dry_run) + apply_configs(DELTA_CONFIGS, dry_run, scope) } -fn apply_configs(configs: GitConfigs, dry_run: bool) -> Result<()> { +fn apply_configs(configs: GitConfigs, dry_run: bool, scope: ConfigScope) -> Result<()> { + let flag = scope_flag(scope); + let mut already_set = 0; + for (key, value) in configs { - if dry_run { - println!("[dry-run] git config --global {key} {value}"); + let current = git_config_get(key, flag); + + if current.as_deref() == Some(value) { + println!("✓ {key} = {value} (already set)"); + already_set += 1; + } else if dry_run { + println!("[dry-run] git config {flag} {key} {value}"); } else { - git_config_set(key, value)?; - println!("Set {key} = {value}"); + git_config_set(key, value, scope)?; + println!("✓ Set {key} = {value}"); } } + + if already_set == configs.len() { + println!("\nAll configs already applied."); + } + Ok(()) } -fn git_config_set(key: &str, value: &str) -> Result<()> { +fn git_config_set(key: &str, value: &str, scope: ConfigScope) -> Result<()> { + let flag = scope_flag(scope); let status = Command::new("git") - .args(["config", "--global", key, value]) + .args(["config", flag, key, value]) .status() .with_context(|| format!("Failed to run git config for '{key}'"))?; - anyhow::ensure!(status.success(), "git config --global {key} {value} failed"); + anyhow::ensure!(status.success(), "git config {flag} {key} {value} failed"); + Ok(()) +} + +pub(crate) fn remove_config_key(key: &str, scope: ConfigScope) -> Result<()> { + let flag = scope_flag(scope); + let status = Command::new("git") + .args(["config", flag, "--unset", key]) + .status() + .with_context(|| format!("Failed to unset git config for '{key}'"))?; + anyhow::ensure!(status.success(), "git config {flag} --unset {key} failed"); Ok(()) } @@ -224,18 +331,35 @@ mod tests { #[test] fn apply_configs_dry_run_prints_without_running_git() { - // dry_run=true must not invoke git; if it did it would fail in CI without a repo - let result = apply_configs(DEFAULTS, true); + let result = apply_configs(DEFAULTS, true, ConfigScope::Global); assert!(result.is_ok()); } #[test] fn apply_configs_dry_run_covers_advanced_preset() { - assert!(apply_configs(ADVANCED, true).is_ok()); + assert!(apply_configs(ADVANCED, true, ConfigScope::Global).is_ok()); } #[test] fn apply_configs_dry_run_covers_delta_preset() { - assert!(apply_configs(DELTA_CONFIGS, true).is_ok()); + assert!(apply_configs(DELTA_CONFIGS, true, ConfigScope::Global).is_ok()); + } + + #[test] + fn determine_scope_defaults_to_global_outside_repo() { + let scope = determine_scope(false, false); + assert!(matches!(scope, ConfigScope::Global | ConfigScope::Local)); + } + + #[test] + fn determine_scope_respects_explicit_flags() { + assert!(matches!(determine_scope(true, false), ConfigScope::Global)); + assert!(matches!(determine_scope(false, true), ConfigScope::Local)); + } + + #[test] + fn scope_flag_returns_correct_values() { + assert_eq!(scope_flag(ConfigScope::Global), "--global"); + assert_eq!(scope_flag(ConfigScope::Local), "--local"); } } diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 2a983f0..e2c0be9 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -81,6 +81,14 @@ pub(crate) fn valid_hook_names() -> &'static [&'static str] { VALID_HOOKS } +/// Identifies which built-in (if any) an installed hook file corresponds to, +/// by exact script comparison. Built-ins are written verbatim on install. +pub(crate) fn detect_builtin(hook_file: &str, content: &str) -> Option<&'static builtins::Builtin> { + builtins::ALL + .iter() + .find(|b| b.hook == hook_file && content.trim() == b.script.trim()) +} + fn hooks_dir() -> Result { Ok(find_repo_root()?.join(".git").join("hooks")) } @@ -211,22 +219,14 @@ fn list(available: bool) -> Result<()> { Ok(()) } -fn remove(hook: &str, yes: bool, dry_run: bool) -> Result<()> { +fn remove(hook: &str, yes: bool, _dry_run: bool) -> Result<()> { + remove_hook(hook, yes) +} + +pub(crate) fn remove_hook(hook: &str, _yes: bool) -> Result<()> { let path = hooks_dir()?.join(hook); anyhow::ensure!(path.exists(), "Hook '{hook}' is not installed"); - - if !confirm(&format!("Remove hook '{hook}'?"), yes) { - println!("Aborted."); - return Ok(()); - } - - if dry_run { - println!("[dry-run] Would remove hook '{hook}'."); - return Ok(()); - } - fs::remove_file(&path).with_context(|| format!("Failed to remove hook '{hook}'"))?; - println!("Removed hook '{hook}'."); Ok(()) } @@ -302,4 +302,25 @@ mod tests { let err = resolve_hook("unknown-builtin", None).unwrap_err(); assert!(err.to_string().contains("not a built-in")); } + + #[test] + fn detect_builtin_distinguishes_builtins_sharing_a_hook_file() { + let no_secrets = builtins::get("no-secrets").unwrap(); + let branch_naming = builtins::get("branch-naming").unwrap(); + assert_eq!( + detect_builtin("pre-commit", no_secrets.script).map(|b| b.name), + Some("no-secrets") + ); + assert_eq!( + detect_builtin("pre-commit", branch_naming.script).map(|b| b.name), + Some("branch-naming") + ); + } + + #[test] + fn detect_builtin_rejects_custom_scripts_and_wrong_hook() { + assert!(detect_builtin("pre-commit", "#!/bin/sh\nset -e\ncargo test\n").is_none()); + let no_secrets = builtins::get("no-secrets").unwrap(); + assert!(detect_builtin("commit-msg", no_secrets.script).is_none()); + } } diff --git a/src/init.rs b/src/init.rs index 6298e33..00a685c 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,7 +1,8 @@ use anyhow::Result; -use inquire::{MultiSelect, Text}; +use inquire::{MultiSelect, Select, Text}; +use std::{collections::HashSet, fs}; -use crate::{attributes, config, git, hooks, ignore}; +use crate::{attributes, builds, config, git, hooks, ignore, utils::find_repo_root}; const BANNER: &str = r#" ███ █████ █████ ███ █████ @@ -27,6 +28,29 @@ pub fn run() -> Result<()> { println!("{BANNER}"); println!(" Configure your git repo\n"); + // ── Build selection ───────────────────────────────────────────────────── + let saved_builds = builds::list_build_names(); + if !saved_builds.is_empty() { + let mut options = vec!["Start fresh configuration".to_string()]; + options.extend(saved_builds.iter().map(|b| format!("Use build: {b}"))); + + let choice = Select::new("Saved builds available", options) + .with_help_message("↑↓ move enter confirm esc start fresh") + .prompt_skippable()?; + + if let Some(build_name) = choice + .as_deref() + .and_then(|c| c.strip_prefix("Use build: ")) + { + println!(); + let build = builds::load_build(build_name)?; + builds::apply_build(&build)?; + println!("\n Done\n"); + return Ok(()); + } + println!(); + } + let cargo_available = std::process::Command::new("cargo") .arg("--version") .output() @@ -35,14 +59,36 @@ pub fn run() -> Result<()> { // ── Hooks ──────────────────────────────────────────────────────────────── let builtins = hooks::available_builtins(); + let installed_hooks = get_installed_hooks(); + let mut hook_items: Vec = builtins .iter() - .map(|b| format!("{:<25} ({}) — {}", b.name, b.hook, b.description)) + .map(|b| { + let base = format!("{:<25} ({}) — {}", b.name, b.hook, b.description); + if installed_hooks.contains(b.name) { + format!("{} [✓ installed]", base) + } else { + base + } + }) .collect(); hook_items.push("Add custom hook...".to_string()); + let preselected: Vec = builtins + .iter() + .enumerate() + .filter(|(_, b)| installed_hooks.contains(b.name)) + .map(|(i, _)| i) + .collect(); + + let default_selection = if preselected.is_empty() { + vec![0usize] + } else { + preselected + }; + let hook_selections = MultiSelect::new("Hooks", hook_items.clone()) - .with_default(&[0usize]) // conventional-commits preselected + .with_default(&default_selection) .with_help_message("↑↓ move space select enter confirm esc skip") .prompt_skippable()? .unwrap_or_default(); @@ -52,12 +98,18 @@ pub fn run() -> Result<()> { for item in &hook_selections { if item == "Add custom hook..." { - let valid = hooks::valid_hook_names().join(", "); - let hook_name = Text::new(" Hook name") - .with_help_message(&format!("Valid: {valid}")) - .prompt()?; - let command = Text::new(" Command to run").prompt()?; - custom_hooks.push((hook_name, command)); + let Some(hook_name) = + Select::new("Hook type", hooks::valid_hook_names().to_vec()).prompt_skippable()? + else { + continue; + }; + let command = Text::new(" Command to run") + .prompt_skippable()? + .unwrap_or_default(); + if command.trim().is_empty() { + continue; + } + custom_hooks.push((hook_name.to_string(), command)); } else if let Some(idx) = hook_items.iter().position(|i| i == item) { if idx < builtins.len() { selected_builtins.push(builtins[idx].name); @@ -65,6 +117,12 @@ pub fn run() -> Result<()> { } } + let hooks_to_remove: Vec<&str> = installed_hooks + .iter() + .filter(|h| !selected_builtins.contains(&h.as_str())) + .map(|s| s.as_str()) + .collect(); + // ── .gitignore ─────────────────────────────────────────────────────────── println!(); let all_templates = load_ignore_templates(); @@ -97,22 +155,34 @@ pub fn run() -> Result<()> { // ── Git config ─────────────────────────────────────────────────────────── println!(); + let configured_keys = get_configured_keys(); + let config_options: Vec<&config::ConfigOption> = config::CONFIG_OPTIONS .iter() .filter(|o| o.key != "core.pager" || cargo_available) .collect(); - let config_labels: Vec<&str> = config_options.iter().map(|o| o.label).collect(); + let config_labels: Vec = config_options + .iter() + .map(|o| { + if configured_keys.contains(o.key) { + format!("{} [✓ already set]", o.label) + } else { + o.label.to_string() + } + }) + .collect(); + + let config_labels_refs: Vec<&str> = config_labels.iter().map(|s| s.as_str()).collect(); - // pre-select recommended ones let defaults: Vec = config_options .iter() .enumerate() - .filter(|(_, o)| o.recommended) + .filter(|(_, o)| o.recommended || configured_keys.contains(o.key)) .map(|(i, _)| i) .collect(); - let config_selections = MultiSelect::new("Git config", config_labels.clone()) + let config_selections = MultiSelect::new("Git config", config_labels_refs.clone()) .with_default(&defaults) .with_help_message("↑↓ move space select enter confirm esc skip") .prompt_skippable()? @@ -120,16 +190,24 @@ pub fn run() -> Result<()> { let selected_config_keys: Vec<&str> = resolve_keys( &config_selections, - &config_labels, + &config_labels_refs, &config_options.iter().map(|o| o.key).collect::>(), ); + let configs_to_remove: Vec<&str> = config_options + .iter() + .filter(|o| configured_keys.contains(o.key) && !selected_config_keys.contains(&o.key)) + .map(|o| o.key) + .collect(); + // ── Summary & confirm ──────────────────────────────────────────────────── + let has_removals = !hooks_to_remove.is_empty() || !configs_to_remove.is_empty(); let nothing = selected_builtins.is_empty() && custom_hooks.is_empty() && selected_templates.is_empty() && selected_attrs.is_empty() - && selected_config_keys.is_empty(); + && selected_config_keys.is_empty() + && !has_removals; if nothing { println!("\n Nothing selected — exiting."); @@ -175,6 +253,23 @@ pub fn run() -> Result<()> { hooks::install_custom(hook, cmd, false)?; println!(" ◇ hook '{hook}' installed ✓"); } + for hook in &hooks_to_remove { + if let Some(builtin) = hooks::available_builtins().iter().find(|b| b.name == *hook) { + // Several built-ins can share a hook file (e.g. pre-commit); don't + // delete the file if a freshly installed selection now owns it. + let file_reused = selected_builtins.iter().any(|sel| { + hooks::available_builtins() + .iter() + .any(|b| b.name == *sel && b.hook == builtin.hook) + }); + if file_reused { + continue; + } + if hooks::remove_hook(builtin.hook, true).is_ok() { + println!(" ◇ hook '{hook}' removed ✓"); + } + } + } if !selected_templates.is_empty() { let joined = selected_templates.join(","); ignore::add_templates(&joined, false)?; @@ -185,9 +280,45 @@ pub fn run() -> Result<()> { println!(" ◇ .gitattributes applied ✓"); } if !selected_config_keys.is_empty() { - config::apply_config_keys(&selected_config_keys, cargo_available)?; + config::apply_config_keys( + &selected_config_keys, + cargo_available, + config::ConfigScope::Local, + )?; println!(" ◇ git config applied ✓"); } + // Only touch the repo's local config; a global value affects every repo, + // so it is never removed from here. + for key in &configs_to_remove { + if config::remove_config_key(key, config::ConfigScope::Local).is_ok() { + println!(" ◇ git config '{key}' removed ✓"); + } else { + println!( + " ◇ git config '{key}' is set globally — left untouched (git config --global --unset {key})" + ); + } + } + + // ── Save as build ───────────────────────────────────────────────────── + println!(); + let save_build = inquire::Confirm::new("Save this configuration as a reusable build?") + .with_default(false) + .prompt()?; + + if save_build { + let name = Text::new(" Build name").prompt()?; + let description = Text::new(" Description (optional)") + .with_default("") + .prompt()?; + let desc_ref = if description.is_empty() { + None + } else { + Some(description.as_str()) + }; + if let Err(e) = builds::save(&name, desc_ref) { + println!(" ⚠ Failed to save build: {e}"); + } + } println!("\n Done\n"); Ok(()) @@ -197,6 +328,70 @@ fn load_ignore_templates() -> Vec { ignore::fetch_template_list().unwrap_or_default() } +fn get_installed_hooks() -> HashSet { + let mut installed = HashSet::new(); + if let Ok(root) = find_repo_root() { + 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 name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".bak") && !name.ends_with(".sample") { + let content = fs::read_to_string(entry.path()).unwrap_or_default(); + if let Some(b) = hooks::detect_builtin(&name, &content) { + installed.insert(b.name.to_string()); + } + } + } + } + } + } + installed +} + +fn get_configured_keys() -> HashSet { + let mut configured = HashSet::new(); + + // Get all config values in one call per scope + let local_configs = get_all_git_configs("--local"); + let global_configs = get_all_git_configs("--global"); + + for option in config::CONFIG_OPTIONS { + if option.key == "core.pager" { + continue; + } + if let Some(expected_value) = option.value { + // Check local first, then global + if local_configs.get(option.key).map(|s| s.as_str()) == Some(expected_value) + || global_configs.get(option.key).map(|s| s.as_str()) == Some(expected_value) + { + configured.insert(option.key.to_string()); + } + } + } + configured +} + +fn get_all_git_configs(scope: &str) -> std::collections::HashMap { + let mut configs = std::collections::HashMap::new(); + + if let Ok(output) = std::process::Command::new("git") + .args(["config", scope, "--list"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some((key, value)) = line.split_once('=') { + configs.insert(key.to_string(), value.to_string()); + } + } + } + } + + configs +} + /// Maps selected display labels back to their corresponding keys. fn resolve_keys<'a>( selections: &[impl AsRef], @@ -213,3 +408,25 @@ fn resolve_keys<'a>( }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_configured_keys_only_returns_known_option_keys() { + let configured = get_configured_keys(); + for key in &configured { + assert!(config::CONFIG_OPTIONS.iter().any(|o| o.key == key)); + } + } + + #[test] + fn resolve_keys_maps_labels_to_keys() { + let selections = vec!["option A", "option C"]; + let labels = vec!["option A", "option B", "option C"]; + let keys = vec!["key_a", "key_b", "key_c"]; + let result = resolve_keys(&selections, &labels, &keys); + assert_eq!(result, vec!["key_a", "key_c"]); + } +} diff --git a/src/main.rs b/src/main.rs index 55ddc74..c6d30fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,14 @@ use anyhow::Result; use clap::{Parser, Subcommand}; mod attributes; +mod builds; mod clone; mod config; mod git; mod hooks; mod ignore; mod init; +mod status; mod utils; #[derive(Parser)] @@ -18,13 +20,15 @@ mod utils; )] struct Cli { #[command(subcommand)] - command: Command, + command: Option, } #[derive(Subcommand)] enum Command { /// Interactive wizard to configure your repo Init, + /// Show current configuration status + Status, /// Clone repository and run init wizard Clone(clone::CloneArgs), /// Manage git hooks @@ -47,16 +51,23 @@ enum Command { #[command(subcommand)] action: config::ConfigCommand, }, + /// Manage saved builds + Build { + #[command(subcommand)] + action: builds::BuildCommand, + }, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Command::Init => init::run(), - Command::Clone(args) => clone::run(args), - Command::Hooks { action } => hooks::run(action), - Command::Ignore { action } => ignore::run(action), - Command::Attributes { action } => attributes::run(action), - Command::Config { action } => config::run(action), + Some(Command::Init) | None => init::run(), + Some(Command::Status) => status::run(), + Some(Command::Clone(args)) => clone::run(args), + Some(Command::Hooks { action }) => hooks::run(action), + Some(Command::Ignore { action }) => ignore::run(action), + Some(Command::Attributes { action }) => attributes::run(action), + Some(Command::Config { action }) => config::run(action), + Some(Command::Build { action }) => builds::run(action), } } diff --git a/src/status/mod.rs b/src/status/mod.rs new file mode 100644 index 0000000..dec677d --- /dev/null +++ b/src/status/mod.rs @@ -0,0 +1,159 @@ +use anyhow::Result; +use std::fs; + +use crate::utils::{find_repo_root, git_config_get}; + +pub fn run() -> Result<()> { + let in_repo = find_repo_root().is_ok(); + + if in_repo { + print_hooks()?; + println!(); + print_gitignore()?; + println!(); + print_gitattributes()?; + println!(); + print_config("local")?; + println!(); + } + + print_config("global")?; + + Ok(()) +} + +fn print_hooks() -> Result<()> { + println!("Hooks:"); + + let root = find_repo_root()?; + let hooks_dir = root.join(".git").join("hooks"); + + if !hooks_dir.exists() { + println!(" (none)"); + return Ok(()); + } + + let installed: Vec<_> = fs::read_dir(&hooks_dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + !s.ends_with(".bak") && !s.ends_with(".sample") + }) + .collect(); + + if installed.is_empty() { + println!(" (none)"); + return Ok(()); + } + + for entry in installed { + let hook_name = entry.file_name().to_string_lossy().to_string(); + let content = fs::read_to_string(entry.path()).unwrap_or_default(); + + match crate::hooks::detect_builtin(&hook_name, &content) { + Some(b) => println!(" ✓ {} ({})", b.name, b.hook), + None => { + let first_cmd = content + .lines() + .find(|l| !l.starts_with('#') && !l.starts_with("set ") && !l.trim().is_empty()) + .unwrap_or("(custom)") + .trim(); + println!(" ✓ custom: {} → {:?}", hook_name, first_cmd); + } + } + } + + Ok(()) +} + +fn print_gitignore() -> Result<()> { + println!(".gitignore:"); + + let root = find_repo_root()?; + let path = root.join(".gitignore"); + + if !path.exists() { + println!(" (none)"); + return Ok(()); + } + + let content = fs::read_to_string(&path)?; + let patterns: Vec<_> = content + .lines() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect(); + + println!(" ✓ {} patterns", patterns.len()); + Ok(()) +} + +fn print_gitattributes() -> Result<()> { + println!(".gitattributes:"); + + let root = find_repo_root()?; + let path = root.join(".gitattributes"); + + if !path.exists() { + println!(" (none)"); + return Ok(()); + } + + let content = fs::read_to_string(&path)?; + let mut presets = Vec::new(); + + if content.contains("eol=lf") { + presets.push("line-endings (eol=lf)"); + } + if content.contains("binary") { + presets.push("binary-files"); + } + + if presets.is_empty() { + println!(" ✓ custom"); + } else { + println!(" ✓ {}", presets.join(", ")); + } + + Ok(()) +} + +fn print_config(scope: &str) -> Result<()> { + let label = if scope == "global" { + "Git config (global)" + } else { + "Git config (local)" + }; + println!("{label}:"); + + let scope_flag = if scope == "global" { + "--global" + } else { + "--local" + }; + + let mut any = false; + for option in crate::config::CONFIG_OPTIONS { + if let Some(value) = git_config_get(option.key, scope_flag) { + println!(" ✓ {} = {value}", option.key); + any = true; + } + } + + if !any { + println!(" (none)"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::utils::git_config_get; + + #[test] + fn git_config_get_returns_none_for_missing_key() { + let result = git_config_get("nonexistent.key.xyz", "--global"); + assert!(result.is_none()); + } +} diff --git a/src/utils.rs b/src/utils.rs index 7e820e2..5a3070f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use std::path::PathBuf; +use std::process::Command; /// Walk up from CWD until we find a `.git` directory, like git itself does. pub(crate) fn find_repo_root() -> Result { @@ -26,6 +27,20 @@ pub(crate) fn confirm(prompt: &str, yes: bool) -> bool { matches!(input.trim(), "y" | "Y") } +/// Get a git config value for a specific key and scope (--global or --local). +pub(crate) fn git_config_get(key: &str, scope: &str) -> Option { + let output = Command::new("git") + .args(["config", scope, "--get", key]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -47,4 +62,10 @@ mod tests { fn confirm_returns_true_when_yes_flag_set() { assert!(confirm("anything?", true)); } + + #[test] + fn git_config_get_returns_none_for_missing_key() { + let result = git_config_get("nonexistent.key.xyz", "--global"); + assert!(result.is_none()); + } }