diff --git a/crates/cli/src/hooks.rs b/crates/cli/src/hooks.rs index c0e18fb..0391a51 100644 --- a/crates/cli/src/hooks.rs +++ b/crates/cli/src/hooks.rs @@ -92,7 +92,9 @@ fn report_post_edit(input: &HookInput, paths: &[PathBuf]) -> Result<()> { } fn report_post_changed(input: &HookInput) -> Result<()> { - let changes = changed_files(&input.cwd)?; + let Some(changes) = repo_changes(&input.cwd)? else { + return Ok(()); + }; let result = scan(&ScanOptions { cwd: &input.cwd, paths: &[], @@ -106,7 +108,10 @@ fn report_post_changed(input: &HookInput) -> Result<()> { } fn report_stop(input: &HookInput) -> Result<()> { - let changes = changed_files(&input.cwd)?; + let Some(changes) = repo_changes(&input.cwd)? else { + reset_counter(&input.session_id)?; + return Ok(()); + }; let result = scan(&ScanOptions { cwd: &input.cwd, paths: &[], @@ -128,6 +133,21 @@ fn report_stop(input: &HookInput) -> Result<()> { )) } +/// Hooks gate only the diff of the repository around `cwd`. Outside a Git +/// repository (or with no `HEAD`) there is nothing to diff, so hooks pass +/// instead of scanning the whole tree the way the CLI fallback does. +fn repo_changes(cwd: &Path) -> Result> { + let changes = changed_files(cwd)?; + if changes.fallback { + eprintln!( + "note: hook skipped: {} is not inside a Git repository with HEAD", + cwd.display() + ); + return Ok(None); + } + Ok(Some(changes)) +} + fn emit_block(reason: &str) -> Result<()> { println!( "{}", diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs index 4159baf..98a0c85 100644 --- a/crates/cli/tests/cli.rs +++ b/crates/cli/tests/cli.rs @@ -183,8 +183,16 @@ fn changed_ignored_paths_are_filtered_before_language_lookup() { fs::write(dir.path().join("target/Foo.kt"), "fun changed() = 2\n").unwrap(); let output = command_output(dir.path(), &["check", "--changed"]); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - assert!(output.stdout.is_empty(), "stdout: {}", String::from_utf8_lossy(&output.stdout)); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stdout.is_empty(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); } #[test] @@ -202,7 +210,11 @@ fn changed_non_utf8_diff_does_not_abort_check_or_stop_hook() { git(dir.path(), &["add", "staged.js"]); let check = command_output(dir.path(), &["check", "--changed"]); - assert!(check.status.success(), "stderr: {}", String::from_utf8_lossy(&check.stderr)); + assert!( + check.status.success(), + "stderr: {}", + String::from_utf8_lossy(&check.stderr) + ); assert_eq!( String::from_utf8_lossy(&check.stdout), "UNVERIFIED staged.js not valid UTF-8\n" @@ -213,7 +225,11 @@ fn changed_non_utf8_diff_does_not_abort_check_or_stop_hook() { }) .to_string(); let stop = hook_output(state.path(), &input); - assert!(stop.status.success(), "stderr: {}", String::from_utf8_lossy(&stop.stderr)); + assert!( + stop.status.success(), + "stderr: {}", + String::from_utf8_lossy(&stop.stderr) + ); assert!(stop.stdout.is_empty()); } @@ -388,3 +404,24 @@ fn complex_function() -> String { .join("\n"); format!("function bad(x) {{\n{decisions}\nreturn x;\n}}\n") } + +#[test] +fn stop_hook_outside_git_repository_passes_without_scanning() { + let dir = tempfile::tempdir().unwrap(); + let state = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("bad.js"), + "function bad(x) { if (x) { if (x > 1) { if (x > 2) { if (x > 3) { if (x > 4) { return 1; } } } } } return 0; }\n", + ) + .unwrap(); + let input = serde_json::json!({ + "hook_event_name":"Stop", "session_id":"no/repo", "cwd":dir.path() + }) + .to_string(); + + let output = hook_output(state.path(), &input); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("note: hook skipped")); + assert!(!state.path().join("no_repo.count").exists()); +} diff --git a/docs/spec.md b/docs/spec.md index 80b9897..ba9eae3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -141,6 +141,10 @@ complexity-gate --version its line span intersects the post-image range of any added/modified hunk. Pure deletions touch nothing. Outside a Git repository, or with no `HEAD`, `--changed` falls back to all given paths (or the cwd) and prints a `note:` line on stderr. + Hook mode (`hook claude|codex`) does not fall back: outside a Git repository + it prints a `note: hook skipped` line, emits no block, and a Stop resets the + loop counter, so a session running from a non-repo cwd is never gated on the + whole tree. The changed file set comes straight from Git: `git diff HEAD` post-image paths (which already include tracked files that a later `.gitignore` rule covers) plus untracked files from `git ls-files --others --exclude-standard`; config