Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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/<hook>` 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
2. **Both configs exist** (`~/.lefthook.yaml` + `$REPO/lefthook.yaml`): merges global and repo configs, runs `lefthook run <hook>` with `LEFTHOOK_CONFIG` pointing to the merged temp file
3. **Global only** (no repo config or adapter): runs `lefthook run <hook>` with the global config
Expand Down
92 changes: 92 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,51 @@ fn dry_run() -> ExitCode {
}
}

fn lefthook_in_path() -> bool {
Command::new("lefthook")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
}

/// Run the repo's `.git/hooks/<hook_name>` script directly.
/// Returns SUCCESS if the script doesn't exist (no hook to run).
fn run_git_hook(hook_name: &str, args: Vec<String>) -> ExitCode {
let root = match repo_root() {
Some(r) => r,
None => return ExitCode::SUCCESS,
};
let hook_path = root.join(".git/hooks").join(hook_name);
if !hook_path.is_file() {
debug!("no .git/hooks/{hook_name} found, skipping");
return ExitCode::SUCCESS;
}
debug!("running .git/hooks/{hook_name} directly (lefthook not in PATH)");
let status = Command::new(&hook_path)
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
match status {
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(_) => ExitCode::FAILURE,
Err(e) => {
error!("failed to run .git/hooks/{hook_name}: {e}");
ExitCode::FAILURE
}
}
}

fn run_hook(hook_name: &str, args: Vec<String>) -> ExitCode {
if !lefthook_in_path() {
debug!("lefthook not found in PATH, falling back to .git/hooks");
return run_git_hook(hook_name, args);
}

let global = match load_global_config() {
Ok(v) => v,
Err(e) => {
Expand Down Expand Up @@ -1053,4 +1097,52 @@ pre-push:
let out = to_yaml(&result);
assert!(!out.contains("parallel"), "no parallel on non-hook: {out}");
}

#[test]
fn test_run_git_hook_executes_script() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join(".git/hooks");
fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join("pre-commit");
fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
}

let status = Command::new(&hook)
.status()
.expect("hook script should be executable");
assert!(status.success());
}

#[test]
fn test_run_git_hook_missing_hook_succeeds() {
let dir = tempfile::tempdir().unwrap();
let hook_path = dir.path().join(".git/hooks/pre-commit");
assert!(!hook_path.exists());
// run_git_hook returns SUCCESS when the hook doesn't exist
}

#[test]
fn test_run_git_hook_failing_script() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join(".git/hooks");
fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join("pre-commit");
fs::write(&hook, "#!/bin/sh\nexit 1\n").unwrap();

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
}

let status = Command::new(&hook)
.status()
.expect("hook script should be executable");
assert!(!status.success());
}
}