diff --git a/README.md b/README.md index 89ab5fe..9051006 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ for repo configs), where `` is `yml`, `yaml`, `json`, `jsonc`, or `toml`. - Creates symlinks for all standard git hooks in `~/.lhm/hooks/`, each pointing to the `lhm` binary - Sets `git config --global core.hooksPath ~/.lhm/hooks` -- With `--default-config`: writes a default `~/.lefthook.yaml` if no global config exists +- Writes a default `~/.lefthook.yaml` if no global config exists ### `lhm dry-run` @@ -36,10 +36,11 @@ lhm dry-run When git triggers a hook, it invokes the symlink in `~/.lhm/hooks/`. `lhm` detects the hook name from `argv[0]` and: 0. **lefthook not in PATH**: falls back to executing `.git/hooks/` directly (if it exists), bypassing all config merging -1. **Global config** is always available: loaded from `~/.lefthook.yaml` if it exists, otherwise a built-in default is used in memory +1. **No config at all** (no global, no repo, no adapter): hook is skipped silently 2. **Both configs exist** (`~/.lefthook.yaml` + `$REPO/lefthook.yaml`): merges global and repo configs, runs `lefthook run ` with `LEFTHOOK_CONFIG` pointing to the merged temp file 3. **Global only** (no repo config or adapter): runs `lefthook run ` with the global config -4. **No repo config, but adapter detected**: generates a dynamic lefthook config from the adapter, merges it with the global config, and runs `lefthook run ` +4. **Repo/adapter only** (no global config): runs `lefthook run ` with the repo or adapter config +5. **No repo config, but adapter detected**: generates a dynamic lefthook config from the adapter, merges it with the global config (if present), and runs `lefthook run ` ### Adapters diff --git a/lefthook.yml b/lefthook.yml index 498419c..700f01e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,7 +3,6 @@ output: - success - failure pre-commit: - parallel: true jobs: - name: fmt stage_fixed: true diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/src/adapters/hooks_dir.rs b/src/adapters/hooks_dir.rs index 3da38aa..fee5c05 100644 --- a/src/adapters/hooks_dir.rs +++ b/src/adapters/hooks_dir.rs @@ -20,10 +20,7 @@ pub struct HooksDirAdapter; /// Return the first hooks directory name that exists as a directory under `root`. fn find_hooks_dir(root: &Path) -> Option<&'static str> { - HOOKS_DIR_NAMES - .iter() - .copied() - .find(|name| root.join(name).is_dir()) + HOOKS_DIR_NAMES.iter().copied().find(|name| root.join(name).is_dir()) } /// Collect sorted filenames from `hooks_dir` that match `hook_name` exactly @@ -144,10 +141,7 @@ mod tests { let config = adapter().generate_config(dir.path(), "pre-commit").unwrap(); let out = serde_yaml::to_string(&config).unwrap(); assert!(out.contains(".hooks/pre-commit"), "uses .hooks: {out}"); - assert!( - !out.contains("git-hooks/pre-commit"), - "does not use git-hooks: {out}" - ); + assert!(!out.contains("git-hooks/pre-commit"), "does not use git-hooks: {out}"); } #[test] @@ -173,10 +167,7 @@ mod tests { let config = adapter().generate_config(dir.path(), "pre-commit").unwrap(); let out = serde_yaml::to_string(&config).unwrap(); assert!(out.contains("pre-commit:"), "has hook key: {out}"); - assert!( - out.contains("git-hooks/pre-commit"), - "has run command: {out}" - ); + assert!(out.contains("git-hooks/pre-commit"), "has run command: {out}"); } #[test] @@ -185,11 +176,7 @@ mod tests { let hooks_dir = dir.path().join(".hooks"); fs::create_dir_all(&hooks_dir).unwrap(); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } #[test] @@ -210,11 +197,7 @@ mod tests { assert!(out.contains("commit-msg:"), "has hook key: {out}"); assert!(out.contains(".hooks/commit-msg"), "has run command: {out}"); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } #[test] @@ -232,23 +215,14 @@ mod tests { let config = adapter().generate_config(dir.path(), "pre-commit").unwrap(); let out = serde_yaml::to_string(&config).unwrap(); assert!(out.contains("hooks-dir:"), "has exact match cmd: {out}"); - assert!( - out.contains("hooks-dir-checkstyle:"), - "has checkstyle cmd: {out}" - ); + assert!(out.contains("hooks-dir-checkstyle:"), "has checkstyle cmd: {out}"); assert!(out.contains("hooks-dir-detekt:"), "has detekt cmd: {out}"); assert!( out.contains(".hooks/pre-commit-checkstyle"), "has checkstyle run: {out}" ); - assert!( - out.contains(".hooks/pre-commit-detekt"), - "has detekt run: {out}" - ); - assert!( - !out.contains("pre-push"), - "should not contain pre-push: {out}" - ); + assert!(out.contains(".hooks/pre-commit-detekt"), "has detekt run: {out}"); + assert!(!out.contains("pre-push"), "should not contain pre-push: {out}"); } #[test] @@ -262,14 +236,8 @@ mod tests { let out = serde_yaml::to_string(&config).unwrap(); assert!(out.contains("pre-commit:"), "has hook key: {out}"); assert!(out.contains("hooks-dir-ktlint:"), "has ktlint cmd: {out}"); - assert!( - out.contains(".hooks/pre-commit-ktlint"), - "has ktlint run: {out}" - ); - assert!( - !out.contains("hooks-dir:\n"), - "should not have exact match cmd: {out}" - ); + assert!(out.contains(".hooks/pre-commit-ktlint"), "has ktlint run: {out}"); + assert!(!out.contains("hooks-dir:\n"), "should not have exact match cmd: {out}"); } #[test] @@ -284,10 +252,7 @@ mod tests { let out = serde_yaml::to_string(&config).unwrap(); assert!(out.contains("hooks-dir:"), "has exact match cmd: {out}"); assert!(out.contains("hooks-dir-detekt:"), "has detekt cmd: {out}"); - assert!( - out.contains("git-hooks/pre-push"), - "uses git-hooks path: {out}" - ); + assert!(out.contains("git-hooks/pre-push"), "uses git-hooks path: {out}"); assert!( out.contains("git-hooks/pre-push-detekt"), "uses git-hooks path for prefixed: {out}" @@ -323,10 +288,7 @@ mod tests { fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\n").unwrap(); let scripts = matching_scripts(&hooks_dir, "pre-commit"); - assert_eq!( - scripts, - vec!["pre-commit", "pre-commit-aaa", "pre-commit-zzz"] - ); + assert_eq!(scripts, vec!["pre-commit", "pre-commit-aaa", "pre-commit-zzz"]); } #[test] diff --git a/src/adapters/husky.rs b/src/adapters/husky.rs index 8ab7e2d..800dab1 100644 --- a/src/adapters/husky.rs +++ b/src/adapters/husky.rs @@ -24,8 +24,7 @@ impl Adapter for HuskyAdapter { return None; } - let config = - format!("{hook_name}:\n commands:\n husky:\n run: .husky/{hook_name}\n"); + let config = format!("{hook_name}:\n commands:\n husky:\n run: .husky/{hook_name}\n"); serde_yaml::from_str(&config).ok() } } @@ -78,11 +77,7 @@ mod tests { let husky_dir = dir.path().join(".husky"); fs::create_dir_all(&husky_dir).unwrap(); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } #[test] @@ -103,10 +98,6 @@ mod tests { assert!(out.contains("commit-msg:"), "has hook key: {out}"); assert!(out.contains(".husky/commit-msg"), "has run command: {out}"); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } } diff --git a/src/adapters/pre_commit.rs b/src/adapters/pre_commit.rs index 13c66eb..0df05bb 100644 --- a/src/adapters/pre_commit.rs +++ b/src/adapters/pre_commit.rs @@ -479,11 +479,7 @@ repos: "#, ); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } #[test] @@ -561,11 +557,7 @@ repos: "#, ); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_some() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_some()); assert!(adapter().generate_config(dir.path(), "pre-push").is_none()); } @@ -586,11 +578,7 @@ repos: "#, ); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); assert!(adapter().generate_config(dir.path(), "pre-push").is_some()); } @@ -613,10 +601,7 @@ repos: let config = adapter().generate_config(dir.path(), "pre-commit").unwrap(); let out = serde_yaml::to_string(&config).unwrap(); - assert!( - out.contains("eslint --fix {staged_files}"), - "entry+args: {out}" - ); + assert!(out.contains("eslint --fix {staged_files}"), "entry+args: {out}"); assert!(out.contains("js"), "glob has js: {out}"); assert!(out.contains("ts"), "glob has ts: {out}"); assert!(out.contains("jsx"), "glob has jsx: {out}"); @@ -627,10 +612,6 @@ repos: fn test_generate_config_empty_repos() { let dir = tempfile::tempdir().unwrap(); write_config(dir.path(), "repos: []\n"); - assert!( - adapter() - .generate_config(dir.path(), "pre-commit") - .is_none() - ); + assert!(adapter().generate_config(dir.path(), "pre-commit").is_none()); } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6319e49 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,219 @@ +use log::{debug, info}; +use serde_yaml::Value; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +pub const LEFTHOOK_EXTENSIONS: &[&str] = &["yml", "yaml", "json", "jsonc", "toml"]; + +pub const DEFAULT_GLOBAL_CONFIG: &str = r#"# Global lefthook configuration +output: + - success + - failure +pre-push: + parallel: true + commands: + test: + run: just test + skip: + - run: "! just --dry-run test" + lint: + run: just lint + skip: + - run: "! just --dry-run lint" +pre-commit: + commands: + fmt: + stage_fixed: true + run: just fmt + skip: + - run: "! just --dry-run fmt" +"#; + +/// Search for a lefthook config file in the given directory. +/// Checks `lefthook.`, `.lefthook.`, and optionally `.config/lefthook.`. +pub fn find_config(dir: &Path, check_dot_config: bool) -> Option { + for ext in LEFTHOOK_EXTENSIONS { + let candidates = if check_dot_config { + vec![ + dir.join(format!("lefthook.{ext}")), + dir.join(format!(".lefthook.{ext}")), + dir.join(format!(".config/lefthook.{ext}")), + ] + } else { + vec![ + dir.join(format!("lefthook.{ext}")), + dir.join(format!(".lefthook.{ext}")), + ] + }; + for candidate in candidates { + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +pub fn global_config(home: &Path) -> Option { + find_config(home, false) +} + +pub fn repo_config(root: &Path) -> Option { + find_config(root, true) +} + +/// Write the default global config to `~/.lefthook.yaml` if no global config exists. +pub fn install_default_global_config(home: &Path) -> Result<(), String> { + if find_config(home, false).is_some() { + debug!("global config already exists, skipping default"); + return Ok(()); + } + let path = home.join(".lefthook.yaml"); + fs::write(&path, DEFAULT_GLOBAL_CONFIG).map_err(|e| format!("failed to write {}: {e}", path.display()))?; + info!("created default global config at {}", path.display()); + Ok(()) +} + +/// Load the global config from `~/.lefthook.yaml` if it exists. +pub fn load_global_config(home: &Path) -> Result, String> { + match global_config(home) { + Some(path) => read_yaml(&path).map(Some), + None => { + debug!("no global config file found"); + Ok(None) + } + } +} + +pub fn read_yaml(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))?; + serde_yaml::from_str(&content).map_err(|e| format!("failed to parse {}: {e}", path.display())) +} + +/// Serialize a merged config value to a temp file for lefthook. +pub fn write_merged_temp(merged: Value) -> Result { + let content = serde_yaml::to_string(&merged).map_err(|e| format!("failed to serialize config: {e}"))?; + debug!("merged config:\n{content}"); + + let mut tmp = tempfile::Builder::new() + .suffix(".yml") + .tempfile() + .map_err(|e| format!("failed to create temp file: {e}"))?; + write!(tmp, "{content}").map_err(|e| format!("failed to write temp config: {e}"))?; + Ok(tmp) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn to_yaml(v: &Value) -> String { + serde_yaml::to_string(v).unwrap() + } + + #[test] + fn test_find_config_yaml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("lefthook.yaml"), "").unwrap(); + assert_eq!(find_config(dir.path(), false), Some(dir.path().join("lefthook.yaml"))); + } + + #[test] + fn test_find_config_yml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("lefthook.yml"), "").unwrap(); + assert_eq!(find_config(dir.path(), false), Some(dir.path().join("lefthook.yml"))); + } + + #[test] + fn test_find_config_toml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("lefthook.toml"), "").unwrap(); + assert_eq!(find_config(dir.path(), false), Some(dir.path().join("lefthook.toml"))); + } + + #[test] + fn test_find_config_dotted() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".lefthook.json"), "").unwrap(); + assert_eq!(find_config(dir.path(), false), Some(dir.path().join(".lefthook.json"))); + } + + #[test] + fn test_find_config_dot_config_subdir() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".config")).unwrap(); + fs::write(dir.path().join(".config/lefthook.toml"), "").unwrap(); + assert_eq!( + find_config(dir.path(), true), + Some(dir.path().join(".config/lefthook.toml")) + ); + // Should not find .config/ variant when check_dot_config is false + assert_eq!(find_config(dir.path(), false), None); + } + + #[test] + fn test_find_config_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_config(dir.path(), true), None); + } + + #[test] + fn test_find_config_prefers_yml_over_yaml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("lefthook.yml"), "").unwrap(); + fs::write(dir.path().join("lefthook.yaml"), "").unwrap(); + // yml comes first in LEFTHOOK_EXTENSIONS + assert_eq!(find_config(dir.path(), false), Some(dir.path().join("lefthook.yml"))); + } + + #[test] + fn test_install_default_global_config_creates_when_missing() { + let dir = tempfile::tempdir().unwrap(); + install_default_global_config(dir.path()).unwrap(); + + let created = dir.path().join(".lefthook.yaml"); + assert!(created.is_file()); + let content = fs::read_to_string(&created).unwrap(); + assert!(content.contains("pre-push:")); + assert!(content.contains("pre-commit:")); + } + + #[test] + fn test_install_default_global_config_skips_when_exists() { + let dir = tempfile::tempdir().unwrap(); + let existing = dir.path().join("lefthook.yml"); + fs::write(&existing, "custom: true\n").unwrap(); + + install_default_global_config(dir.path()).unwrap(); + + // Original file untouched + assert_eq!(fs::read_to_string(&existing).unwrap(), "custom: true\n"); + // No .lefthook.yaml created + assert!(!dir.path().join(".lefthook.yaml").exists()); + } + + #[test] + fn test_load_global_config_returns_none_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let result = load_global_config(dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_load_global_config_returns_some_when_exists() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(".lefthook.yaml"), + "pre-commit:\n commands:\n fmt:\n run: echo hi\n", + ) + .unwrap(); + let result = load_global_config(dir.path()).unwrap(); + assert!(result.is_some()); + let out = to_yaml(&result.unwrap()); + assert!(out.contains("pre-commit:")); + } +} diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..5e93d8c --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,242 @@ +use log::debug; +use serde_yaml::Value; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +pub const GIT_HOOKS: &[&str] = &[ + "applypatch-msg", + "commit-msg", + "post-applypatch", + "post-checkout", + "post-commit", + "post-merge", + "post-rewrite", + "pre-applypatch", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "prepare-commit-msg", +]; + +pub fn is_hook_name(name: &str) -> bool { + GIT_HOOKS.contains(&name) +} + +/// Hooks where commands mutate shared state and must not run in parallel. +/// - `pre-commit` / `pre-merge-commit`: formatters mutate the working tree/index +/// - `prepare-commit-msg` / `commit-msg` / `applypatch-msg`: edit a single message file +const SERIAL_HOOKS: &[&str] = &[ + "applypatch-msg", + "commit-msg", + "pre-commit", + "pre-merge-commit", + "prepare-commit-msg", +]; + +pub fn create_hook_symlinks(dir: &Path, binary: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + + remove_stale_hooks(dir); + + for hook in GIT_HOOKS { + let link = dir.join(hook); + let _ = fs::remove_file(&link); + symlink(binary, &link).map_err(|e| format!("failed to symlink {}: {e}", link.display()))?; + } + Ok(()) +} + +/// Remove any entries in the hooks dir that aren't in the current `GIT_HOOKS` list. +fn remove_stale_hooks(dir: &Path) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + if !GIT_HOOKS.contains(&name_str) { + debug!("removing stale hook: {name_str}"); + let _ = fs::remove_file(entry.path()); + } + } +} + +/// Annotate adapter-generated config with lefthook settings: +/// - `parallel: true` on hooks that don't mutate shared state +/// - `stage_fixed: true` on each command within `pre-commit` and `pre-merge-commit` hooks +pub fn annotate_hooks(config: Value) -> Value { + let Value::Mapping(mut root) = config else { + return config; + }; + for (key, val) in &mut root { + if let (Some(name), Value::Mapping(hook_map)) = (key.as_str(), val) + && is_hook_name(name) + { + if !SERIAL_HOOKS.contains(&name) { + hook_map.insert(Value::String("parallel".to_string()), Value::Bool(true)); + } + if name == "pre-commit" || name == "pre-merge-commit" { + set_stage_fixed(hook_map); + } + } + } + Value::Mapping(root) +} + +/// Add `stage_fixed: true` to every command in a hook mapping. +fn set_stage_fixed(hook_map: &mut serde_yaml::Mapping) { + let commands_key = Value::String("commands".to_string()); + if let Some(Value::Mapping(commands)) = hook_map.get_mut(&commands_key) { + for (_cmd_name, cmd_val) in commands.iter_mut() { + if let Value::Mapping(cmd_map) = cmd_val { + cmd_map.insert(Value::String("stage_fixed".to_string()), Value::Bool(true)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn yaml(s: &str) -> Value { + serde_yaml::from_str(s).unwrap() + } + + fn to_yaml(v: &Value) -> String { + serde_yaml::to_string(v).unwrap() + } + + #[test] + fn test_is_hook_name() { + assert!(is_hook_name("pre-commit")); + assert!(is_hook_name("commit-msg")); + assert!(is_hook_name("pre-push")); + assert!(is_hook_name("post-merge")); + assert!(!is_hook_name("lhm")); + assert!(!is_hook_name("cargo")); + assert!(!is_hook_name("")); + } + + #[test] + fn test_create_hook_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let hooks = dir.path().join("hooks"); + let fake_binary = dir.path().join("lhm"); + fs::write(&fake_binary, "fake").unwrap(); + + create_hook_symlinks(&hooks, &fake_binary).unwrap(); + + for hook in GIT_HOOKS { + let link = hooks.join(hook); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), fake_binary); + } + } + + #[test] + fn test_create_hook_symlinks_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let hooks = dir.path().join("hooks"); + fs::create_dir_all(&hooks).unwrap(); + + // Create a pre-existing file where a symlink will go + fs::write(hooks.join("pre-commit"), "old").unwrap(); + + let fake_binary = dir.path().join("lhm"); + fs::write(&fake_binary, "fake").unwrap(); + + create_hook_symlinks(&hooks, &fake_binary).unwrap(); + + let link = hooks.join("pre-commit"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), fake_binary); + } + + #[test] + fn test_create_hook_symlinks_removes_stale_hooks() { + let dir = tempfile::tempdir().unwrap(); + let hooks = dir.path().join("hooks"); + fs::create_dir_all(&hooks).unwrap(); + + // Create hooks that are no longer in GIT_HOOKS + let stale = ["reference-transaction", "fsmonitor-watchman", "update"]; + for name in &stale { + fs::write(hooks.join(name), "old").unwrap(); + } + + let fake_binary = dir.path().join("lhm"); + fs::write(&fake_binary, "fake").unwrap(); + + create_hook_symlinks(&hooks, &fake_binary).unwrap(); + + for name in &stale { + assert!(!hooks.join(name).exists(), "stale hook {name} should be removed"); + } + for hook in GIT_HOOKS { + assert!(hooks.join(hook).symlink_metadata().unwrap().file_type().is_symlink()); + } + } + + #[test] + fn test_annotate_hooks_parallel_on_safe_hooks() { + let config = yaml("pre-push:\n commands:\n foo:\n run: echo hi\noutput:\n - success\n"); + let result = annotate_hooks(config); + let out = to_yaml(&result); + assert!(out.contains("parallel: true"), "injects parallel: {out}"); + assert!(out.contains("output:"), "non-hook keys preserved: {out}"); + } + + #[test] + fn test_annotate_hooks_no_parallel_on_serial_hooks() { + for hook in SERIAL_HOOKS { + let config = yaml(&format!("{hook}:\n commands:\n foo:\n run: echo hi\n")); + let result = annotate_hooks(config); + let out = to_yaml(&result); + assert!(!out.contains("parallel"), "no parallel on {hook}: {out}"); + } + } + + #[test] + fn test_annotate_hooks_stage_fixed_on_pre_commit_hooks() { + for hook in &["pre-commit", "pre-merge-commit"] { + let config = yaml(&format!( + "{hook}:\n commands:\n foo:\n run: echo hi\n bar:\n run: echo bye\n" + )); + let result = annotate_hooks(config); + let out = to_yaml(&result); + assert!( + out.contains("stage_fixed: true"), + "injects stage_fixed on {hook}: {out}" + ); + assert!(!out.contains("parallel"), "no parallel on {hook}: {out}"); + assert_eq!( + out.matches("stage_fixed").count(), + 2, + "both commands get stage_fixed on {hook}: {out}" + ); + } + } + + #[test] + fn test_annotate_hooks_no_stage_fixed_on_pre_push() { + let config = yaml("pre-push:\n commands:\n foo:\n run: echo hi\n"); + let result = annotate_hooks(config); + let out = to_yaml(&result); + assert!(!out.contains("stage_fixed"), "no stage_fixed on pre-push: {out}"); + } + + #[test] + fn test_annotate_hooks_skips_non_hook_keys() { + let config = yaml("output:\n - success\n"); + let result = annotate_hooks(config); + let out = to_yaml(&result); + assert!(!out.contains("parallel"), "no parallel on non-hook: {out}"); + } +} diff --git a/src/main.rs b/src/main.rs index 484b672..dc72a1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,18 @@ mod adapters; +mod config; +mod hooks; +mod merge; use clap::{Parser, Subcommand}; use log::{debug, error, info}; use serde_yaml::Value; use std::env; -use std::fs; -use std::io::Write; -use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; -use tempfile::NamedTempFile; + +use config::{install_default_global_config, load_global_config, read_yaml, repo_config, write_merged_temp}; +use hooks::{GIT_HOOKS, annotate_hooks, create_hook_symlinks, is_hook_name}; +use merge::merge_configs; fn init_logger(cli_debug: bool) { let debug_enabled = cli_debug || env::var("LHM_DEBUG").is_ok_and(|v| v == "1" || v == "true"); @@ -38,22 +41,6 @@ fn init_logger(cli_debug: bool) { .init(); } -const GIT_HOOKS: &[&str] = &[ - "applypatch-msg", - "commit-msg", - "post-applypatch", - "post-checkout", - "post-commit", - "post-merge", - "post-rewrite", - "pre-applypatch", - "pre-commit", - "pre-merge-commit", - "pre-push", - "pre-rebase", - "prepare-commit-msg", -]; - #[derive(Parser)] #[command( name = "lhm", @@ -61,8 +48,8 @@ const GIT_HOOKS: &[&str] = &[ Merges global and per-repo lefthook configs. When invoked as a git hook (via symlink), lhm finds the global config \ -(~/.lefthook.yaml) and repo config ($REPO/lefthook.yaml), merges them using lefthook's \ -extends mechanism, and runs lefthook. If neither config exists, falls back to \ +(~/.lefthook.yaml) and repo config ($REPO/lefthook.yaml), merges them, \ +and runs lefthook. If neither config exists, falls back to \ the adapter system. Supported config names: lefthook., .lefthook., .config/lefthook. @@ -80,11 +67,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Configure global core.hooksPath to use lhm - Install { - /// Write the default global config to ~/.lefthook.yaml - #[arg(long)] - default_config: bool, - }, + Install, /// Print the merged config that would be used, then exit DryRun, } @@ -101,7 +84,7 @@ fn main() -> ExitCode { let cli = Cli::parse(); init_logger(cli.debug); match cli.command { - Commands::Install { default_config } => install(default_config), + Commands::Install => install(), Commands::DryRun => dry_run(), } } @@ -116,10 +99,6 @@ fn invoked_name() -> String { .to_string() } -fn is_hook_name(name: &str) -> bool { - GIT_HOOKS.contains(&name) -} - fn home_dir() -> PathBuf { env::var("HOME").map(PathBuf::from).expect("HOME not set") } @@ -128,71 +107,6 @@ fn hooks_dir() -> PathBuf { home_dir().join(".lhm").join("hooks") } -const LEFTHOOK_EXTENSIONS: &[&str] = &["yml", "yaml", "json", "jsonc", "toml"]; - -const DEFAULT_GLOBAL_CONFIG: &str = r#"# Global lefthook configuration -output: - - success - - failure -pre-push: - parallel: true - commands: - test: - run: just test - skip: - - run: lefthook --dry-run test - lint: - run: just lint - skip: - - run: lefthook --dry-run lint -prepare-commit-msg: - commands: - aittributor: - run: aittributor {1} - skip: - - run: which aittributor > /dev/null -pre-commit: - commands: - fmt: - stage_fixed: true - run: just fmt - skip: - - run: lefthook --dry-run fmt -"#; - -/// Search for a lefthook config file in the given directory. -/// Checks `lefthook.`, `.lefthook.`, and optionally `.config/lefthook.`. -fn find_config(dir: &Path, check_dot_config: bool) -> Option { - for ext in LEFTHOOK_EXTENSIONS { - let candidates = if check_dot_config { - vec![ - dir.join(format!("lefthook.{ext}")), - dir.join(format!(".lefthook.{ext}")), - dir.join(format!(".config/lefthook.{ext}")), - ] - } else { - vec![ - dir.join(format!("lefthook.{ext}")), - dir.join(format!(".lefthook.{ext}")), - ] - }; - for candidate in candidates { - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -fn global_config() -> Option { - find_config(&home_dir(), false) -} - -fn repo_config(root: &Path) -> Option { - find_config(root, true) -} - fn repo_root() -> Option { Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -203,43 +117,13 @@ fn repo_root() -> Option { .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim())) } -/// Write the default global config to `~/.lefthook.yaml` if no global config exists. -fn install_default_global_config(home: &Path) -> Result<(), String> { - if find_config(home, false).is_some() { - debug!("global config already exists, skipping default"); - return Ok(()); - } - let path = home.join(".lefthook.yaml"); - fs::write(&path, DEFAULT_GLOBAL_CONFIG) - .map_err(|e| format!("failed to write {}: {e}", path.display()))?; - info!("created default global config at {}", path.display()); - Ok(()) -} - -/// Parse `DEFAULT_GLOBAL_CONFIG` as YAML. -fn parse_default_global_config() -> Value { - serde_yaml::from_str(DEFAULT_GLOBAL_CONFIG).expect("default config is valid YAML") -} - -/// Load the effective global config: from `~/.lefthook.yaml` if it exists, -/// otherwise fall back to the built-in `DEFAULT_GLOBAL_CONFIG`. -fn load_global_config() -> Result { - match global_config() { - Some(path) => read_yaml(&path), - None => { - debug!("no global config file found, using built-in default"); - Ok(parse_default_global_config()) - } - } -} - -fn install(default_config: bool) -> ExitCode { +fn install() -> ExitCode { let dir = hooks_dir(); let binary = env::current_exe().expect("cannot determine lhm binary path"); debug!("hooks dir: {}", dir.display()); debug!("binary path: {}", binary.display()); - if default_config && let Err(e) = install_default_global_config(&home_dir()) { + if let Err(e) = install_default_global_config(&home_dir()) { error!("{e}"); return ExitCode::FAILURE; } @@ -267,82 +151,6 @@ fn install(default_config: bool) -> ExitCode { } } -fn create_hook_symlinks(dir: &Path, binary: &Path) -> Result<(), String> { - fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; - - remove_stale_hooks(dir); - - for hook in GIT_HOOKS { - let link = dir.join(hook); - let _ = fs::remove_file(&link); - symlink(binary, &link).map_err(|e| format!("failed to symlink {}: {e}", link.display()))?; - } - Ok(()) -} - -/// Remove any entries in the hooks dir that aren't in the current `GIT_HOOKS` list. -fn remove_stale_hooks(dir: &Path) { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - if !GIT_HOOKS.contains(&name_str) { - debug!("removing stale hook: {name_str}"); - let _ = fs::remove_file(entry.path()); - } - } -} - -/// Hooks where commands mutate shared state and must not run in parallel. -/// - `pre-commit` / `pre-merge-commit`: formatters mutate the working tree/index -/// - `prepare-commit-msg` / `commit-msg` / `applypatch-msg`: edit a single message file -const SERIAL_HOOKS: &[&str] = &[ - "applypatch-msg", - "commit-msg", - "pre-commit", - "pre-merge-commit", - "prepare-commit-msg", -]; - -/// Annotate adapter-generated config with lefthook settings: -/// - `parallel: true` on hooks that don't mutate shared state -/// - `stage_fixed: true` on each command within `pre-commit` and `pre-merge-commit` hooks -fn annotate_hooks(config: Value) -> Value { - let Value::Mapping(mut root) = config else { - return config; - }; - for (key, val) in &mut root { - if let (Some(name), Value::Mapping(hook_map)) = (key.as_str(), val) - && is_hook_name(name) - { - if !SERIAL_HOOKS.contains(&name) { - hook_map.insert(Value::String("parallel".to_string()), Value::Bool(true)); - } - if name == "pre-commit" || name == "pre-merge-commit" { - set_stage_fixed(hook_map); - } - } - } - Value::Mapping(root) -} - -/// Add `stage_fixed: true` to every command in a hook mapping. -fn set_stage_fixed(hook_map: &mut serde_yaml::Mapping) { - let commands_key = Value::String("commands".to_string()); - if let Some(Value::Mapping(commands)) = hook_map.get_mut(&commands_key) { - for (_cmd_name, cmd_val) in commands.iter_mut() { - if let Value::Mapping(cmd_map) = cmd_val { - cmd_map.insert(Value::String("stage_fixed".to_string()), Value::Bool(true)); - } - } - } -} - fn adapter_config_for(root: &Path, hook_name: Option<&str>) -> Option { let adapter = adapters::detect_adapter(root)?; debug!("detected adapter: {}", adapter.name()); @@ -369,22 +177,25 @@ fn adapter_config_for(root: &Path, hook_name: Option<&str>) -> Option { /// Resolve global, repo, and adapter sources into a single merged config. fn resolve_config( - global: &Value, + global: &Option, repo: &Option, adapter_config: &Option, -) -> Result { - match (repo, adapter_config) { - (Some(r), _) => { +) -> Result, String> { + match (global, repo, adapter_config) { + (Some(g), Some(r), _) => { let rv = read_yaml(r)?; - Ok(merge_configs(global.clone(), rv)) + Ok(Some(merge_configs(g.clone(), rv))) } - (None, Some(av)) => Ok(merge_configs(global.clone(), av.clone())), - (None, None) => Ok(global.clone()), + (Some(g), None, Some(av)) => Ok(Some(merge_configs(g.clone(), av.clone()))), + (Some(g), None, None) => Ok(Some(g.clone())), + (None, Some(r), _) => read_yaml(r).map(Some), + (None, None, Some(av)) => Ok(Some(av.clone())), + (None, None, None) => Ok(None), } } fn dry_run() -> ExitCode { - let global = match load_global_config() { + let global = match load_global_config(&home_dir()) { Ok(v) => v, Err(e) => { error!("{e}"); @@ -405,10 +216,14 @@ fn dry_run() -> ExitCode { } match resolve_config(&global, &repo, &adapter_config) { - Ok(config) => { + Ok(Some(config)) => { print!("{}", serde_yaml::to_string(&config).unwrap_or_default()); ExitCode::SUCCESS } + Ok(None) => { + debug!("no config to display"); + ExitCode::SUCCESS + } Err(e) => { error!("{e}"); ExitCode::FAILURE @@ -461,7 +276,7 @@ fn run_hook(hook_name: &str, args: Vec) -> ExitCode { return run_git_hook(hook_name, args); } - let global = match load_global_config() { + let global = match load_global_config(&home_dir()) { Ok(v) => v, Err(e) => { error!("{e}"); @@ -475,20 +290,24 @@ fn run_hook(hook_name: &str, args: Vec) -> ExitCode { debug!("repo config: {:?}", repo); let adapter_config = if repo.is_none() { - root.as_deref() - .and_then(|r| adapter_config_for(r, Some(hook_name))) + root.as_deref().and_then(|r| adapter_config_for(r, Some(hook_name))) } else { None }; - let _temp = match resolve_config(&global, &repo, &adapter_config) { - Ok(merged) => match write_merged_temp(merged) { - Ok(t) => t, - Err(e) => { - error!("{e}"); - return ExitCode::FAILURE; - } - }, + let merged = match resolve_config(&global, &repo, &adapter_config) { + Ok(Some(m)) => m, + Ok(None) => { + debug!("no config found, skipping hook"); + return ExitCode::SUCCESS; + } + Err(e) => { + error!("{e}"); + return ExitCode::FAILURE; + } + }; + let _temp = match write_merged_temp(merged) { + Ok(t) => t, Err(e) => { error!("{e}"); return ExitCode::FAILURE; @@ -497,10 +316,7 @@ fn run_hook(hook_name: &str, args: Vec) -> ExitCode { let config_path = _temp.path(); debug!("LEFTHOOK_CONFIG={}", config_path.display()); - debug!( - "running: lefthook run {hook_name} --no-auto-install {}", - args.join(" ") - ); + debug!("running: lefthook run {hook_name} --no-auto-install {}", args.join(" ")); let status = Command::new("lefthook") .arg("run") @@ -523,626 +339,10 @@ fn run_hook(hook_name: &str, args: Vec) -> ExitCode { } } -/// Serialize a merged config value to a temp file for lefthook. -fn write_merged_temp(merged: Value) -> Result { - let content = - serde_yaml::to_string(&merged).map_err(|e| format!("failed to serialize config: {e}"))?; - debug!("merged config:\n{content}"); - - let mut tmp = tempfile::Builder::new() - .suffix(".yml") - .tempfile() - .map_err(|e| format!("failed to create temp file: {e}"))?; - write!(tmp, "{content}").map_err(|e| format!("failed to write temp config: {e}"))?; - Ok(tmp) -} - -fn read_yaml(path: &Path) -> Result { - let content = - fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))?; - serde_yaml::from_str(&content).map_err(|e| format!("failed to parse {}: {e}", path.display())) -} - -/// Merge two lefthook configs. Repo takes precedence over global. -fn merge_configs(global: Value, repo: Value) -> Value { - match (global, repo) { - (Value::Mapping(mut global), Value::Mapping(repo)) => { - for (key, repo_val) in repo { - let key_str = key.as_str().unwrap_or(""); - if is_hook_name(key_str) { - if let Some(global_val) = global.remove(&key) { - global.insert(key, merge_hook(global_val, repo_val)); - } else { - global.insert(key, repo_val); - } - } else { - global.insert(key, repo_val); - } - } - Value::Mapping(global) - } - (_, repo) => repo, - } -} - -/// Merge two hook definitions. For commands/scripts maps, merge by name. -/// For jobs lists, merge named jobs by name and append unnamed ones. -/// When formats differ (commands vs jobs), repo names suppress matching global names. -/// For all other keys, repo wins. -fn merge_hook(global: Value, repo: Value) -> Value { - match (global, repo) { - (Value::Mapping(mut global), Value::Mapping(repo)) => { - // Collect repo task names across all formats for cross-format dedup - let repo_task_names = collect_task_names_from_mapping(&repo); - - // Remove global tasks that are overridden by repo (cross-format) - if !repo_task_names.is_empty() { - strip_names_from_commands(&mut global, &repo_task_names); - strip_names_from_scripts(&mut global, &repo_task_names); - strip_names_from_jobs(&mut global, &repo_task_names); - } - - for (key, repo_val) in repo { - let key_str = key.as_str().unwrap_or(""); - match key_str { - "commands" | "scripts" => { - if let Some(global_val) = global.remove(&key) { - global.insert(key, merge_maps(global_val, repo_val)); - } else { - global.insert(key, repo_val); - } - } - "jobs" => { - if let Some(global_val) = global.remove(&key) { - global.insert(key, merge_jobs(global_val, repo_val)); - } else { - global.insert(key, repo_val); - } - } - _ => { - global.insert(key, repo_val); - } - } - } - - Value::Mapping(global) - } - (_, repo) => repo, - } -} - -fn collect_task_names_from_mapping(mapping: &serde_yaml::Mapping) -> Vec { - let mut names = Vec::new(); - - // Names from commands/scripts (map keys) - for section in ["commands", "scripts"] { - if let Some(Value::Mapping(m)) = mapping.get(Value::String(section.to_string())) { - for key in m.keys() { - if let Some(s) = key.as_str() { - names.push(s.to_string()); - } - } - } - } - - // Names from jobs (name field) - if let Some(Value::Sequence(jobs)) = mapping.get(Value::String("jobs".to_string())) { - for job in jobs { - if let Some(name) = job - .as_mapping() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - { - names.push(name.to_string()); - } - } - } - - names -} - -fn strip_names_from_commands(mapping: &mut serde_yaml::Mapping, names: &[String]) { - let key = Value::String("commands".to_string()); - if let Some(Value::Mapping(cmds)) = mapping.get_mut(&key) { - cmds.retain(|k, _| k.as_str().is_none_or(|s| !names.contains(&s.to_string()))); - if cmds.is_empty() { - mapping.remove(&key); - } - } -} - -fn strip_names_from_scripts(mapping: &mut serde_yaml::Mapping, names: &[String]) { - let key = Value::String("scripts".to_string()); - if let Some(Value::Mapping(scripts)) = mapping.get_mut(&key) { - scripts.retain(|k, _| k.as_str().is_none_or(|s| !names.contains(&s.to_string()))); - if scripts.is_empty() { - mapping.remove(&key); - } - } -} - -fn strip_names_from_jobs(mapping: &mut serde_yaml::Mapping, names: &[String]) { - let key = Value::String("jobs".to_string()); - if let Some(Value::Sequence(jobs)) = mapping.get_mut(&key) { - jobs.retain(|job| { - job.as_mapping() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - .is_none_or(|name| !names.contains(&name.to_string())) - }); - if jobs.is_empty() { - mapping.remove(&key); - } - } -} - -/// Merge two YAML maps by key. Repo values override global values. -fn merge_maps(global: Value, repo: Value) -> Value { - match (global, repo) { - (Value::Mapping(mut global), Value::Mapping(repo)) => { - for (key, repo_val) in repo { - global.insert(key, repo_val); - } - Value::Mapping(global) - } - (_, repo) => repo, - } -} - -/// Merge two jobs lists. Named jobs (with `name` field) are merged by name -/// with repo taking precedence. Unnamed jobs are appended (global first, then repo). -fn merge_jobs(global: Value, repo: Value) -> Value { - match (&global, &repo) { - (Value::Sequence(global_jobs), Value::Sequence(repo_jobs)) => { - fn job_name(job: &Value) -> Option<&str> { - job.as_mapping() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - } - - let repo_names: Vec> = repo_jobs.iter().map(|j| job_name(j)).collect(); - - let mut result: Vec = Vec::new(); - - // Add global jobs, skipping named ones that repo overrides - for job in global_jobs { - if let Some(name) = job_name(job) - && repo_names.contains(&Some(name)) - { - continue; - } - result.push(job.clone()); - } - - // Add all repo jobs - result.extend(repo_jobs.iter().cloned()); - - Value::Sequence(result) - } - _ => repo, - } -} - #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_is_hook_name() { - assert!(is_hook_name("pre-commit")); - assert!(is_hook_name("commit-msg")); - assert!(is_hook_name("pre-push")); - assert!(is_hook_name("post-merge")); - assert!(!is_hook_name("lhm")); - assert!(!is_hook_name("cargo")); - assert!(!is_hook_name("")); - } - - #[test] - fn test_create_hook_symlinks() { - let dir = tempfile::tempdir().unwrap(); - let hooks = dir.path().join("hooks"); - let fake_binary = dir.path().join("lhm"); - fs::write(&fake_binary, "fake").unwrap(); - - create_hook_symlinks(&hooks, &fake_binary).unwrap(); - - for hook in GIT_HOOKS { - let link = hooks.join(hook); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert_eq!(fs::read_link(&link).unwrap(), fake_binary); - } - } - - #[test] - fn test_create_hook_symlinks_overwrites_existing() { - let dir = tempfile::tempdir().unwrap(); - let hooks = dir.path().join("hooks"); - fs::create_dir_all(&hooks).unwrap(); - - // Create a pre-existing file where a symlink will go - fs::write(hooks.join("pre-commit"), "old").unwrap(); - - let fake_binary = dir.path().join("lhm"); - fs::write(&fake_binary, "fake").unwrap(); - - create_hook_symlinks(&hooks, &fake_binary).unwrap(); - - let link = hooks.join("pre-commit"); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert_eq!(fs::read_link(&link).unwrap(), fake_binary); - } - - #[test] - fn test_create_hook_symlinks_removes_stale_hooks() { - let dir = tempfile::tempdir().unwrap(); - let hooks = dir.path().join("hooks"); - fs::create_dir_all(&hooks).unwrap(); - - // Create hooks that are no longer in GIT_HOOKS - let stale = ["reference-transaction", "fsmonitor-watchman", "update"]; - for name in &stale { - fs::write(hooks.join(name), "old").unwrap(); - } - - let fake_binary = dir.path().join("lhm"); - fs::write(&fake_binary, "fake").unwrap(); - - create_hook_symlinks(&hooks, &fake_binary).unwrap(); - - for name in &stale { - assert!( - !hooks.join(name).exists(), - "stale hook {name} should be removed" - ); - } - for hook in GIT_HOOKS { - assert!( - hooks - .join(hook) - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink() - ); - } - } - - #[test] - fn test_find_config_yaml() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("lefthook.yaml"), "").unwrap(); - assert_eq!( - find_config(dir.path(), false), - Some(dir.path().join("lefthook.yaml")) - ); - } - - #[test] - fn test_find_config_yml() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("lefthook.yml"), "").unwrap(); - assert_eq!( - find_config(dir.path(), false), - Some(dir.path().join("lefthook.yml")) - ); - } - - #[test] - fn test_find_config_toml() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("lefthook.toml"), "").unwrap(); - assert_eq!( - find_config(dir.path(), false), - Some(dir.path().join("lefthook.toml")) - ); - } - - #[test] - fn test_find_config_dotted() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join(".lefthook.json"), "").unwrap(); - assert_eq!( - find_config(dir.path(), false), - Some(dir.path().join(".lefthook.json")) - ); - } - - #[test] - fn test_find_config_dot_config_subdir() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".config")).unwrap(); - fs::write(dir.path().join(".config/lefthook.toml"), "").unwrap(); - assert_eq!( - find_config(dir.path(), true), - Some(dir.path().join(".config/lefthook.toml")) - ); - // Should not find .config/ variant when check_dot_config is false - assert_eq!(find_config(dir.path(), false), None); - } - - #[test] - fn test_find_config_none() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(find_config(dir.path(), true), None); - } - - #[test] - fn test_find_config_prefers_yml_over_yaml() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("lefthook.yml"), "").unwrap(); - fs::write(dir.path().join("lefthook.yaml"), "").unwrap(); - // yml comes first in LEFTHOOK_EXTENSIONS - assert_eq!( - find_config(dir.path(), false), - Some(dir.path().join("lefthook.yml")) - ); - } - - fn yaml(s: &str) -> Value { - serde_yaml::from_str(s).unwrap() - } - - fn to_yaml(v: &Value) -> String { - serde_yaml::to_string(v).unwrap() - } - - #[test] - fn test_merge_configs_repo_overrides_scalars() { - let global = yaml("output:\n - success\nmin_version: '1.0'\n"); - let repo = yaml("output:\n - failure\nskip_lfs: true\n"); - let merged = merge_configs(global, repo); - let out = to_yaml(&merged); - assert!(out.contains("skip_lfs: true")); - assert!(out.contains("failure")); - assert!(out.contains("min_version")); - } - - #[test] - fn test_merge_configs_commands_dedup() { - let global = yaml( - "pre-push:\n commands:\n test:\n run: global-test\n lint:\n run: global-lint\n", - ); - let repo = yaml("pre-push:\n commands:\n test:\n run: repo-test\n"); - let merged = merge_configs(global, repo); - let out = to_yaml(&merged); - assert!(out.contains("repo-test"), "repo should win: {out}"); - assert!( - !out.contains("global-test"), - "global test should be gone: {out}" - ); - assert!( - out.contains("global-lint"), - "global-only lint preserved: {out}" - ); - } - - #[test] - fn test_merge_configs_cross_format_commands_vs_jobs() { - let global = yaml( - "pre-push:\n commands:\n test:\n run: global-test\n lint:\n run: global-lint\n", - ); - let repo = yaml( - "pre-push:\n jobs:\n - name: test\n run: repo-test\n - name: lint\n run: repo-lint\n", - ); - let merged = merge_configs(global, repo); - let out = to_yaml(&merged); - // Global commands with same names should be stripped - assert!(!out.contains("global-test"), "global test stripped: {out}"); - assert!(!out.contains("global-lint"), "global lint stripped: {out}"); - // Repo jobs should be present - assert!(out.contains("repo-test"), "repo test present: {out}"); - assert!(out.contains("repo-lint"), "repo lint present: {out}"); - } - - #[test] - fn test_merge_configs_global_only_hook_preserved() { - let global = - yaml("prepare-commit-msg:\n commands:\n aittributor:\n run: aittributor\n"); - let repo = yaml("pre-commit:\n jobs:\n - name: fmt\n run: just fmt\n"); - let merged = merge_configs(global, repo); - let out = to_yaml(&merged); - assert!( - out.contains("prepare-commit-msg"), - "global-only hook kept: {out}" - ); - assert!(out.contains("aittributor"), "global command kept: {out}"); - assert!(out.contains("pre-commit"), "repo hook kept: {out}"); - } - - #[test] - fn test_merge_jobs_named_dedup() { - let global = - yaml("- name: test\n run: global-test\n- name: unique\n run: global-unique\n"); - let repo = yaml("- name: test\n run: repo-test\n"); - let merged = merge_jobs(global, repo); - let out = to_yaml(&merged); - assert!(out.contains("repo-test"), "repo wins: {out}"); - assert!(!out.contains("global-test"), "global test removed: {out}"); - assert!(out.contains("global-unique"), "global-only job kept: {out}"); - } - - #[test] - fn test_merge_jobs_unnamed_appended() { - let global = yaml("- run: global-unnamed\n"); - let repo = yaml("- run: repo-unnamed\n"); - let merged = merge_jobs(global, repo); - let out = to_yaml(&merged); - assert!(out.contains("global-unnamed"), "global unnamed kept: {out}"); - assert!(out.contains("repo-unnamed"), "repo unnamed kept: {out}"); - } - - #[test] - fn test_merge_real_configs() { - let global = yaml( - r#" -output: - - success - - failure -pre-push: - parallel: true - commands: - test: - run: grep -qe ^test Justfile 2> /dev/null && just test - lint: - run: grep -qe ^lint Justfile 2> /dev/null && just lint -prepare-commit-msg: - commands: - aittributor: - run: aittributor {1} -pre-commit: - commands: - fmt: - run: grep -qe ^fmt Justfile 2> /dev/null && just fmt -"#, - ); - let repo = yaml( - r#" -skip_lfs: true -output: - - success - - failure -pre-commit: - parallel: true - jobs: - - name: fmt - run: just fmt -pre-push: - parallel: true - jobs: - - name: lint - run: just lint - - name: test - run: just test -"#, - ); - let merged = merge_configs(global, repo); - let out = to_yaml(&merged); - - // Repo scalars win - assert!(out.contains("skip_lfs: true"), "repo skip_lfs: {out}"); - - // Global-only hook preserved - assert!( - out.contains("prepare-commit-msg"), - "global hook kept: {out}" - ); - assert!(out.contains("aittributor"), "global command kept: {out}"); - - // No duplicate commands — global commands with same names stripped - assert!( - !out.contains("grep -qe ^test"), - "global test stripped: {out}" - ); - assert!( - !out.contains("grep -qe ^lint"), - "global lint stripped: {out}" - ); - assert!(!out.contains("grep -qe ^fmt"), "global fmt stripped: {out}"); - - // Repo jobs present - assert!(out.contains("just fmt"), "repo fmt: {out}"); - assert!(out.contains("just lint"), "repo lint: {out}"); - assert!(out.contains("just test"), "repo test: {out}"); - } - - #[test] - fn test_install_default_global_config_creates_when_missing() { - let dir = tempfile::tempdir().unwrap(); - install_default_global_config(dir.path()).unwrap(); - - let created = dir.path().join(".lefthook.yaml"); - assert!(created.is_file()); - let content = fs::read_to_string(&created).unwrap(); - assert!(content.contains("pre-push:")); - assert!(content.contains("pre-commit:")); - assert!(content.contains("prepare-commit-msg:")); - } - - #[test] - fn test_install_default_global_config_skips_when_exists() { - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("lefthook.yml"); - fs::write(&existing, "custom: true\n").unwrap(); - - install_default_global_config(dir.path()).unwrap(); - - // Original file untouched - assert_eq!(fs::read_to_string(&existing).unwrap(), "custom: true\n"); - // No .lefthook.yaml created - assert!(!dir.path().join(".lefthook.yaml").exists()); - } - - #[test] - fn test_parse_default_global_config() { - let val = parse_default_global_config(); - let out = to_yaml(&val); - assert!(out.contains("pre-push:")); - assert!(out.contains("pre-commit:")); - assert!(out.contains("prepare-commit-msg:")); - } - - #[test] - fn test_annotate_hooks_parallel_on_safe_hooks() { - let config = - yaml("pre-push:\n commands:\n foo:\n run: echo hi\noutput:\n - success\n"); - let result = annotate_hooks(config); - let out = to_yaml(&result); - assert!(out.contains("parallel: true"), "injects parallel: {out}"); - assert!(out.contains("output:"), "non-hook keys preserved: {out}"); - } - - #[test] - fn test_annotate_hooks_no_parallel_on_serial_hooks() { - for hook in SERIAL_HOOKS { - let config = yaml(&format!( - "{hook}:\n commands:\n foo:\n run: echo hi\n" - )); - let result = annotate_hooks(config); - let out = to_yaml(&result); - assert!(!out.contains("parallel"), "no parallel on {hook}: {out}"); - } - } - - #[test] - fn test_annotate_hooks_stage_fixed_on_pre_commit_hooks() { - for hook in &["pre-commit", "pre-merge-commit"] { - let config = yaml(&format!( - "{hook}:\n commands:\n foo:\n run: echo hi\n bar:\n run: echo bye\n" - )); - let result = annotate_hooks(config); - let out = to_yaml(&result); - assert!( - out.contains("stage_fixed: true"), - "injects stage_fixed on {hook}: {out}" - ); - assert!(!out.contains("parallel"), "no parallel on {hook}: {out}"); - assert_eq!( - out.matches("stage_fixed").count(), - 2, - "both commands get stage_fixed on {hook}: {out}" - ); - } - } - - #[test] - fn test_annotate_hooks_no_stage_fixed_on_pre_push() { - let config = yaml("pre-push:\n commands:\n foo:\n run: echo hi\n"); - let result = annotate_hooks(config); - let out = to_yaml(&result); - assert!( - !out.contains("stage_fixed"), - "no stage_fixed on pre-push: {out}" - ); - } - - #[test] - fn test_annotate_hooks_skips_non_hook_keys() { - let config = yaml("output:\n - success\n"); - let result = annotate_hooks(config); - let out = to_yaml(&result); - assert!(!out.contains("parallel"), "no parallel on non-hook: {out}"); - } + use std::fs; + use std::process::Command; #[test] fn test_run_git_hook_executes_script() { @@ -1158,9 +358,7 @@ pre-push: fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap(); } - let status = Command::new(&hook) - .status() - .expect("hook script should be executable"); + let status = Command::new(&hook).status().expect("hook script should be executable"); assert!(status.success()); } @@ -1186,9 +384,7 @@ pre-push: fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap(); } - let status = Command::new(&hook) - .status() - .expect("hook script should be executable"); + let status = Command::new(&hook).status().expect("hook script should be executable"); assert!(!status.success()); } } diff --git a/src/merge.rs b/src/merge.rs new file mode 100644 index 0000000..1bcfe0e --- /dev/null +++ b/src/merge.rs @@ -0,0 +1,326 @@ +use crate::hooks::is_hook_name; +use serde_yaml::Value; + +/// Merge two lefthook configs. Repo takes precedence over global. +pub fn merge_configs(global: Value, repo: Value) -> Value { + match (global, repo) { + (Value::Mapping(mut global), Value::Mapping(repo)) => { + for (key, repo_val) in repo { + let key_str = key.as_str().unwrap_or(""); + if is_hook_name(key_str) { + if let Some(global_val) = global.remove(&key) { + global.insert(key, merge_hook(global_val, repo_val)); + } else { + global.insert(key, repo_val); + } + } else { + global.insert(key, repo_val); + } + } + Value::Mapping(global) + } + (_, repo) => repo, + } +} + +/// Merge two hook definitions. For commands/scripts maps, merge by name. +/// For jobs lists, merge named jobs by name and append unnamed ones. +/// When formats differ (commands vs jobs), repo names suppress matching global names. +/// For all other keys, repo wins. +fn merge_hook(global: Value, repo: Value) -> Value { + match (global, repo) { + (Value::Mapping(mut global), Value::Mapping(repo)) => { + // Collect repo task names across all formats for cross-format dedup + let repo_task_names = collect_task_names_from_mapping(&repo); + + // Remove global tasks that are overridden by repo (cross-format) + if !repo_task_names.is_empty() { + strip_names_from_commands(&mut global, &repo_task_names); + strip_names_from_scripts(&mut global, &repo_task_names); + strip_names_from_jobs(&mut global, &repo_task_names); + } + + for (key, repo_val) in repo { + let key_str = key.as_str().unwrap_or(""); + match key_str { + "commands" | "scripts" => { + if let Some(global_val) = global.remove(&key) { + global.insert(key, merge_maps(global_val, repo_val)); + } else { + global.insert(key, repo_val); + } + } + "jobs" => { + if let Some(global_val) = global.remove(&key) { + global.insert(key, merge_jobs(global_val, repo_val)); + } else { + global.insert(key, repo_val); + } + } + _ => { + global.insert(key, repo_val); + } + } + } + + Value::Mapping(global) + } + (_, repo) => repo, + } +} + +fn collect_task_names_from_mapping(mapping: &serde_yaml::Mapping) -> Vec { + let mut names = Vec::new(); + + // Names from commands/scripts (map keys) + for section in ["commands", "scripts"] { + if let Some(Value::Mapping(m)) = mapping.get(Value::String(section.to_string())) { + for key in m.keys() { + if let Some(s) = key.as_str() { + names.push(s.to_string()); + } + } + } + } + + // Names from jobs (name field) + if let Some(Value::Sequence(jobs)) = mapping.get(Value::String("jobs".to_string())) { + for job in jobs { + if let Some(name) = job.as_mapping().and_then(|m| m.get("name")).and_then(|v| v.as_str()) { + names.push(name.to_string()); + } + } + } + + names +} + +fn strip_names_from_commands(mapping: &mut serde_yaml::Mapping, names: &[String]) { + let key = Value::String("commands".to_string()); + if let Some(Value::Mapping(cmds)) = mapping.get_mut(&key) { + cmds.retain(|k, _| k.as_str().is_none_or(|s| !names.contains(&s.to_string()))); + if cmds.is_empty() { + mapping.remove(&key); + } + } +} + +fn strip_names_from_scripts(mapping: &mut serde_yaml::Mapping, names: &[String]) { + let key = Value::String("scripts".to_string()); + if let Some(Value::Mapping(scripts)) = mapping.get_mut(&key) { + scripts.retain(|k, _| k.as_str().is_none_or(|s| !names.contains(&s.to_string()))); + if scripts.is_empty() { + mapping.remove(&key); + } + } +} + +fn strip_names_from_jobs(mapping: &mut serde_yaml::Mapping, names: &[String]) { + let key = Value::String("jobs".to_string()); + if let Some(Value::Sequence(jobs)) = mapping.get_mut(&key) { + jobs.retain(|job| { + job.as_mapping() + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .is_none_or(|name| !names.contains(&name.to_string())) + }); + if jobs.is_empty() { + mapping.remove(&key); + } + } +} + +/// Merge two YAML maps by key. Repo values override global values. +fn merge_maps(global: Value, repo: Value) -> Value { + match (global, repo) { + (Value::Mapping(mut global), Value::Mapping(repo)) => { + for (key, repo_val) in repo { + global.insert(key, repo_val); + } + Value::Mapping(global) + } + (_, repo) => repo, + } +} + +/// Merge two jobs lists. Named jobs (with `name` field) are merged by name +/// with repo taking precedence. Unnamed jobs are appended (global first, then repo). +fn merge_jobs(global: Value, repo: Value) -> Value { + match (&global, &repo) { + (Value::Sequence(global_jobs), Value::Sequence(repo_jobs)) => { + fn job_name(job: &Value) -> Option<&str> { + job.as_mapping().and_then(|m| m.get("name")).and_then(|v| v.as_str()) + } + + let repo_names: Vec> = repo_jobs.iter().map(|j| job_name(j)).collect(); + + let mut result: Vec = Vec::new(); + + // Add global jobs, skipping named ones that repo overrides + for job in global_jobs { + if let Some(name) = job_name(job) + && repo_names.contains(&Some(name)) + { + continue; + } + result.push(job.clone()); + } + + // Add all repo jobs + result.extend(repo_jobs.iter().cloned()); + + Value::Sequence(result) + } + _ => repo, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn yaml(s: &str) -> Value { + serde_yaml::from_str(s).unwrap() + } + + fn to_yaml(v: &Value) -> String { + serde_yaml::to_string(v).unwrap() + } + + #[test] + fn test_merge_configs_repo_overrides_scalars() { + let global = yaml("output:\n - success\nmin_version: '1.0'\n"); + let repo = yaml("output:\n - failure\nskip_lfs: true\n"); + let merged = merge_configs(global, repo); + let out = to_yaml(&merged); + assert!(out.contains("skip_lfs: true")); + assert!(out.contains("failure")); + assert!(out.contains("min_version")); + } + + #[test] + fn test_merge_configs_commands_dedup() { + let global = + yaml("pre-push:\n commands:\n test:\n run: global-test\n lint:\n run: global-lint\n"); + let repo = yaml("pre-push:\n commands:\n test:\n run: repo-test\n"); + let merged = merge_configs(global, repo); + let out = to_yaml(&merged); + assert!(out.contains("repo-test"), "repo should win: {out}"); + assert!(!out.contains("global-test"), "global test should be gone: {out}"); + assert!(out.contains("global-lint"), "global-only lint preserved: {out}"); + } + + #[test] + fn test_merge_configs_cross_format_commands_vs_jobs() { + let global = + yaml("pre-push:\n commands:\n test:\n run: global-test\n lint:\n run: global-lint\n"); + let repo = yaml( + "pre-push:\n jobs:\n - name: test\n run: repo-test\n - name: lint\n run: repo-lint\n", + ); + let merged = merge_configs(global, repo); + let out = to_yaml(&merged); + // Global commands with same names should be stripped + assert!(!out.contains("global-test"), "global test stripped: {out}"); + assert!(!out.contains("global-lint"), "global lint stripped: {out}"); + // Repo jobs should be present + assert!(out.contains("repo-test"), "repo test present: {out}"); + assert!(out.contains("repo-lint"), "repo lint present: {out}"); + } + + #[test] + fn test_merge_configs_global_only_hook_preserved() { + let global = yaml("prepare-commit-msg:\n commands:\n aittributor:\n run: aittributor\n"); + let repo = yaml("pre-commit:\n jobs:\n - name: fmt\n run: just fmt\n"); + let merged = merge_configs(global, repo); + let out = to_yaml(&merged); + assert!(out.contains("prepare-commit-msg"), "global-only hook kept: {out}"); + assert!(out.contains("aittributor"), "global command kept: {out}"); + assert!(out.contains("pre-commit"), "repo hook kept: {out}"); + } + + #[test] + fn test_merge_jobs_named_dedup() { + let global = yaml("- name: test\n run: global-test\n- name: unique\n run: global-unique\n"); + let repo = yaml("- name: test\n run: repo-test\n"); + let merged = merge_jobs(global, repo); + let out = to_yaml(&merged); + assert!(out.contains("repo-test"), "repo wins: {out}"); + assert!(!out.contains("global-test"), "global test removed: {out}"); + assert!(out.contains("global-unique"), "global-only job kept: {out}"); + } + + #[test] + fn test_merge_jobs_unnamed_appended() { + let global = yaml("- run: global-unnamed\n"); + let repo = yaml("- run: repo-unnamed\n"); + let merged = merge_jobs(global, repo); + let out = to_yaml(&merged); + assert!(out.contains("global-unnamed"), "global unnamed kept: {out}"); + assert!(out.contains("repo-unnamed"), "repo unnamed kept: {out}"); + } + + #[test] + fn test_merge_real_configs() { + let global = yaml( + r#" +output: + - success + - failure +pre-push: + parallel: true + commands: + test: + run: grep -qe ^test Justfile 2> /dev/null && just test + lint: + run: grep -qe ^lint Justfile 2> /dev/null && just lint +prepare-commit-msg: + commands: + aittributor: + run: aittributor {1} +pre-commit: + commands: + fmt: + run: grep -qe ^fmt Justfile 2> /dev/null && just fmt +"#, + ); + let repo = yaml( + r#" +skip_lfs: true +output: + - success + - failure +pre-commit: + parallel: true + jobs: + - name: fmt + run: just fmt +pre-push: + parallel: true + jobs: + - name: lint + run: just lint + - name: test + run: just test +"#, + ); + let merged = merge_configs(global, repo); + let out = to_yaml(&merged); + + // Repo scalars win + assert!(out.contains("skip_lfs: true"), "repo skip_lfs: {out}"); + + // Global-only hook preserved + assert!(out.contains("prepare-commit-msg"), "global hook kept: {out}"); + assert!(out.contains("aittributor"), "global command kept: {out}"); + + // No duplicate commands — global commands with same names stripped + assert!(!out.contains("grep -qe ^test"), "global test stripped: {out}"); + assert!(!out.contains("grep -qe ^lint"), "global lint stripped: {out}"); + assert!(!out.contains("grep -qe ^fmt"), "global fmt stripped: {out}"); + + // Repo jobs present + assert!(out.contains("just fmt"), "repo fmt: {out}"); + assert!(out.contains("just lint"), "repo lint: {out}"); + assert!(out.contains("just test"), "repo test: {out}"); + } +}