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
24 changes: 22 additions & 2 deletions crates/cli/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: &[],
Expand All @@ -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: &[],
Expand All @@ -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<Option<complexity_gate_core::ChangedFiles>> {
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!(
"{}",
Expand Down
45 changes: 41 additions & 4 deletions crates/cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
Expand All @@ -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());
}

Expand Down Expand Up @@ -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());
}
4 changes: 4 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading