import json import os import subprocess import threading import time import pytest from conftest import ( GIT_ENV, HOOKS_DIR, STUB_VULN, VULN_PY, bash_payload, commit_file, edit_payload, git, make_repo, metrics_of, run_hook, stop_payload, ups_payload, ) import gitutil import reporesolve as rr import security_reminder_hook as hook class TestDirsFromCommand: def test_cd_and_and(self, workspace): ws, repo = workspace assert rr.dirs_from_command("cd sub && git commit -m x", str(ws)) == [str(repo)] def test_cd_semicolon_subshell(self, workspace): ws, repo = workspace assert rr.dirs_from_command("(cd sub; git commit -m x)", str(ws)) == [str(repo)] def test_git_dash_C(self, workspace): ws, repo = workspace assert rr.dirs_from_command("git -C sub commit -q -m x", str(ws)) == [str(repo)] def test_git_dash_C_absolute_and_quoted(self, tmp_path): d = tmp_path / "my repo" d.mkdir() assert rr.dirs_from_command(f'git -C "{d}" commit -m "a b"', "/nonexistent") == [str(d)] def test_env_prefix_and_config_opts(self, workspace): ws, repo = workspace cmd = "FOO=1 git -c user.name=x -C sub commit -m x" assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)] def test_git_dir_work_tree(self, workspace): ws, repo = workspace cmd = "git --git-dir=sub/.git --work-tree=sub commit -m x" assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)] cmd = "git --git-dir sub/.git commit -m x" assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)] def test_subcommand_filter(self, workspace): ws, repo = workspace cmd = "git -C other status && git -C sub commit -m x && git -C third push" assert rr.dirs_from_command(cmd, str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)] assert rr.dirs_from_command(cmd, str(ws), rr.PUSH_SUBCOMMANDS) == [str(ws / "third")] def test_gt(self, workspace): ws, repo = workspace assert rr.dirs_from_command("cd sub && gt create -m x", str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)] def test_pushd_and_relative_chain(self, workspace): ws, repo = workspace cmd = "pushd sub && cd .. && cd ./sub && git commit -m x" assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)] @pytest.mark.parametrize("cmd", [ 'git -C "unterminated commit -m x', "git -C=sub commit -m x", "cd && git commit", "cd - && git commit -m x", "git", "git -C", "cd /definitely/not/here && git commit -m x", "echo 'git commit' | cat", "git commit -m \"$(cat <<'EOF'\nmsg\nEOF\n)\"", "", None, ]) def test_odd_inputs_do_not_raise(self, workspace, cmd): ws, _ = workspace out = rr.dirs_from_command(cmd, str(ws)) assert isinstance(out, list) assert rr.toplevel_from_command(cmd, str(ws)) in (None, str(ws / "sub")) def test_toplevel_from_command_nonexistent_dir(self, workspace): ws, _ = workspace assert rr.toplevel_from_command("cd nope && git commit -m x", str(ws)) is None def test_toplevel_from_subdir_of_repo(self, workspace): ws, repo = workspace (repo / "pkg").mkdir() assert rr.toplevel_from_command("cd sub/pkg && git commit -m x", str(ws)) == str(repo) def test_windows_backslash_paths_survive_tokenizing(self, workspace, monkeypatch): ws, _ = workspace monkeypatch.setattr(rr.os, "sep", "\\") toks = rr._tokenize(r'git -C C:\Users\me\repo commit -m x && git -C "D:\a b\r" push') assert r"C:\Users\me\repo" in toks and r"D:\a b\r" in toks toks = rr._tokenize(r"git -C \\srv\share\repo commit -m x") assert r"\\srv\share\repo" in toks class TestShaScan: def test_finds_repo_containing_commit(self, workspace): ws, repo = workspace sha, _ = commit_file(repo, "app.py", VULN_PY) assert rr.repo_containing_commit(sha[:7], [str(ws)]) == str(repo) assert rr.repo_containing_commit(sha, [str(ws)]) == str(repo) def test_unknown_sha(self, workspace): ws, _ = workspace assert rr.repo_containing_commit("deadbeefdeadbeef", [str(ws)]) is None def test_invalid_sha(self, workspace): ws, _ = workspace assert rr.repo_containing_commit("HEAD; rm -rf x", [str(ws)]) is None assert rr.repo_containing_commit(None, [str(ws)]) is None def test_skips_ignored_dirs_and_respects_depth(self, tmp_path): ws = tmp_path / "ws" deep = make_repo(ws / "a" / "b" / "c" / "d" / "repo") nm = make_repo(ws / "node_modules" / "pkg") found = list(rr.iter_git_repos([str(ws)], max_depth=3)) assert str(deep) not in found and str(nm) not in found found = list(rr.iter_git_repos([str(ws)], max_depth=6)) assert str(deep) in found and str(nm) not in found def test_max_repos_cap(self, tmp_path): ws = tmp_path / "ws" for i in range(4): make_repo(ws / f"r{i}") assert len(list(rr.iter_git_repos([str(ws)], max_repos=2))) == 2 class TestReposFromPaths: def test_groups_by_toplevel_and_orders_by_count(self, tmp_path): ws = tmp_path / "ws" a = make_repo(ws / "a") b = make_repo(ws / "b") paths = [str(b / "x.py"), str(a / "one.py"), str(a / "pkg" / "two.py"), str(ws / "loose.txt"), "", None, 42] assert rr.repos_from_paths(paths, str(ws)) == [str(a), str(b)] def test_relative_paths_resolve_against_cwd(self, workspace): ws, repo = workspace assert rr.repos_from_paths(["sub/app.py"], str(ws)) == [str(repo)] def test_missing_parent_dirs(self, workspace): ws, repo = workspace assert rr.repos_from_paths([str(repo / "new" / "deeper" / "f.py")], str(ws)) == [str(repo)] class TestResolveRepoRoot: def test_cwd_is_repo(self, workspace): ws, repo = workspace assert rr.resolve_repo_root(str(repo), "cd /tmp && git commit") == (str(repo), rr.RES_CWD) def test_cwd_is_repo_same_repo_in_command_stays_cwd(self, workspace): ws, repo = workspace (repo / "pkg").mkdir() for cmd in ("git commit -m x", "cd pkg && git commit -m x", f"git -C {repo} commit -m x", "git -C pkg commit -m x", "git -C nope commit -m x"): assert rr.resolve_repo_root(str(repo), cmd, rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD), cmd def test_explicit_other_repo_in_command_beats_cwd(self, workspace): ws, repo = workspace other = make_repo(ws / "other") assert rr.resolve_repo_root(str(repo), f"git -C {other} commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND) assert rr.resolve_repo_root(str(repo), "git -C ../other push", rr.PUSH_SUBCOMMANDS) == (str(other), rr.RES_COMMAND) assert rr.resolve_repo_root(str(repo), "cd ../other && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND) assert rr.resolve_repo_root(str(repo), "git --git-dir=../other/.git --work-tree=../other commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND) assert rr.resolve_repo_root(str(repo), f"git -C {other} status && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD) def test_order_command_then_paths_then_sha(self, workspace): ws, repo = workspace other = make_repo(ws / "other") sha, _ = commit_file(repo, "app.py", VULN_PY) assert rr.resolve_repo_root(str(ws), "git -C other commit", rr.COMMIT_SUBCOMMANDS, sha=sha, touched_paths=[str(repo / "app.py")]) == (str(other), rr.RES_COMMAND) assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS, sha=sha, touched_paths=[str(repo / "app.py")]) == (str(repo), rr.RES_TOUCHED_PATHS) assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS, sha=sha) == (str(repo), rr.RES_SHA_SCAN) assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE) class TestRegexes: @pytest.mark.parametrize("cmd", [ "git commit -m x", "git -C sub commit -m x", 'git -C "a b" commit -m x', "git -c core.editor=true -C sub commit --amend", "git --git-dir=x/.git --work-tree=x commit -m x", "git --git-dir x/.git commit -m x", "cd sub && git commit -m x", "gt create -m x", ]) def test_commit_re(self, cmd): assert hook._GIT_COMMIT_RE.search(cmd) @pytest.mark.parametrize("cmd", [ "git push", "git -C sub push origin main", 'git -C "a b" push', "git --work-tree x push -u origin HEAD", "gt submit", ]) def test_push_re(self, cmd): assert hook._GIT_PUSH_RE.search(cmd) @pytest.mark.parametrize("cmd", ["git status", "git log --oneline", "echo commit"]) def test_commit_re_negative(self, cmd): assert not hook._GIT_COMMIT_RE.search(cmd) @pytest.mark.parametrize("unit", ['-c "a"', "-c 'a'", "--no-a=b", "--a=b=c=d", "--git-dir x"]) @pytest.mark.parametrize("regex,verb", [("_GIT_COMMIT_RE", "commit"), ("_GIT_PUSH_RE", "push")]) def test_global_option_prefix_is_linear(self, regex, verb, unit): cre = getattr(hook, regex) prefix = "git " + " ".join([unit] * 40) t0 = time.perf_counter() assert not cre.search(prefix + " " + verb + "foo") assert time.perf_counter() - t0 < 0.05 assert cre.search(prefix + " " + verb + " -m x") class TestHooksJson: def test_matchers_and_events(self): cfg = json.loads((HOOKS_DIR / "hooks.json").read_text()) assert "SubagentStop" in cfg["hooks"] assert cfg["hooks"]["SubagentStop"][0]["hooks"][0]["command"] == \ cfg["hooks"]["Stop"][0]["hooks"][0]["command"] bash = [g for g in cfg["hooks"]["PostToolUse"] if g.get("matcher") == "Bash"][0] ifs = {h.get("if") for h in bash["hooks"]} assert {"Bash(git commit:*)", "Bash(git push:*)", "Bash(git -C * commit *)", "Bash(git -C * push*)", "Bash(gt create:*)", "Bash(gt modify:*)", "Bash(gt submit:*)"} <= ifs def _read_state(env): d = env["SECURITY_WARNINGS_STATE_DIR"] files = [e.path for e in os.scandir(d) if e.name.endswith(".json")] assert files, os.listdir(d) with open(files[0]) as f: return json.load(f) def _read_state_or_empty(env): d = env["SECURITY_WARNINGS_STATE_DIR"] if not any(e.name.endswith(".json") for e in os.scandir(d)): return {} return _read_state(env) class TestCommitReview: def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) rc, so, se = run_hook(bash_payload(repo, "git commit -m change", out), hook_env) m = metrics_of(so) assert m["commit_review"] is True assert "cwd_is_repo" not in m and "repo_resolution" not in m assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None assert stub_api.calls def test_cwd_is_repo_cd_subdir_unchanged(self, workspace, hook_env, stub_api): ws, repo = workspace (repo / "pkg").mkdir() sha, out = commit_file(repo, "pkg/app.py", VULN_PY) rc, so, se = run_hook(bash_payload(repo, "cd pkg && git commit -m change", out), hook_env) m = metrics_of(so) assert "cwd_is_repo" not in m and "repo_resolution" not in m assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None def test_cwd_is_repo_dash_C_other_repo_reviews_other_repo(self, workspace, hook_env, stub_api): ws, repo = workspace other = make_repo(ws / "other") sha, out = commit_file(other, "srv.py", VULN_PY) rc, so, se = run_hook(bash_payload(repo, f"git -C {other} commit -m change", out), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert "cwd_is_repo" not in m and m["repo_resolution"] == rr.RES_COMMAND assert m["files_reviewed"] == 1 assert stub_api.calls assert (other / ".git" / "sg-reviewed-shas").exists() assert not (repo / ".git" / "sg-reviewed-shas").exists() def test_workspace_cwd_cd_sub(self, workspace, hook_env, stub_api): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND assert m["files_reviewed"] == 1 assert stub_api.calls def test_workspace_cwd_git_dash_C_quiet_uses_reflog(self, workspace, hook_env, stub_api): ws, repo = workspace sha, _ = commit_file(repo, "app.py", VULN_PY) rc, so, se = run_hook(bash_payload(ws, "git -C sub commit -q -m change", ""), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["repo_resolution"] == rr.RES_COMMAND and m["sha_via_reflog"] is True assert m["files_reviewed"] == 1 def test_workspace_cwd_sha_scan(self, workspace, hook_env, stub_api): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) rc, so, se = run_hook(bash_payload(ws, "git commit -m change", out), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["repo_resolution"] == rr.RES_SHA_SCAN and m["files_reviewed"] == 1 assert "repo_root_hint" not in _read_state_or_empty(hook_env) def test_workspace_cwd_sha_scan_via_project_dir(self, workspace, hook_env, tmp_path): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() env = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)} rc, so, se = run_hook(bash_payload(elsewhere, "git commit -m change", out), env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["repo_resolution"] == rr.RES_SHA_SCAN def test_hint_saved_and_used(self, workspace, hook_env): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env) state = _read_state(hook_env) assert state.get("repo_root_hint") == str(repo) assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE) os.environ["SECURITY_WARNINGS_STATE_DIR"] = hook_env["SECURITY_WARNINGS_STATE_DIR"] try: assert rr.load_repo_hint("s1") == str(repo) finally: os.environ.pop("SECURITY_WARNINGS_STATE_DIR", None) def test_hint_used_for_quiet_commit_without_dir(self, workspace, hook_env): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env) commit_file(repo, "app.py", VULN_PY + "# 2\n") rc, so, se = run_hook(bash_payload(ws, "git commit -q -m change", ""), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["repo_resolution"] == rr.RES_HINT and m["sha_via_reflog"] is True def test_unresolvable_still_skips_26(self, workspace, hook_env, tmp_path): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) empty = tmp_path / "empty" empty.mkdir() rc, so, se = run_hook(bash_payload(empty, "git commit -m change", out), hook_env) m = metrics_of(so) assert m["skip_reason"] == 26 and m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_NONE def test_no_credentials_gate_precedes_repo_resolution(self, workspace, hook_env): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) env = {k: v for k, v in hook_env.items() if k != "ANTHROPIC_API_KEY"} rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), env) m = metrics_of(so) assert m["skip_reason"] == 22 and m["repo_resolution"] == rr.RES_COMMAND def test_dedup_sentinel_uses_resolved_repo(self, workspace, hook_env): ws, repo = workspace sha, out = commit_file(repo, "app.py", VULN_PY) p = bash_payload(ws, "git -C sub commit -m change && git -C sub push", out, tool_use_id="toolu_1") rc1, so1, _ = run_hook(p, hook_env) rc2, so2, _ = run_hook(p, hook_env) assert metrics_of(so1).get("commit_review") is True assert metrics_of(so2) == {"bash_hook_dedup": True} class TestPushSweep: def test_workspace_cwd_git_dash_C_push(self, workspace, hook_env, tmp_path): ws, repo = workspace remote = tmp_path / "remote.git" git(ws, "init", "-q", "--bare", str(remote)) git(repo, "remote", "add", "origin", str(remote)) git(repo, "push", "-q", "-u", "origin", "main") base = git(repo, "rev-parse", "HEAD").strip() sha, _ = commit_file(repo, "app.py", VULN_PY) out = git(repo, "push", "--porcelain", "origin", "main") push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n" rc, so, se = run_hook(bash_payload(ws, "git -C sub push origin main", push_stdout), hook_env) m = metrics_of(so) assert m["push_sweep"] is True assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND assert m.get("skip_reason") != 26 assert m.get("pushed") == 1 def test_workspace_cwd_sha_scan_push_leaves_no_hint(self, workspace, hook_env, tmp_path): ws, repo = workspace remote = tmp_path / "remote.git" git(ws, "init", "-q", "--bare", str(remote)) git(repo, "remote", "add", "origin", str(remote)) git(repo, "push", "-q", "-u", "origin", "main") base = git(repo, "rev-parse", "HEAD").strip() sha, _ = commit_file(repo, "app.py", VULN_PY) git(repo, "push", "-q", "origin", "main") push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n" rc, so, se = run_hook(bash_payload(ws, "git push origin main", push_stdout), hook_env) m = metrics_of(so) assert m["push_sweep"] is True and m["repo_resolution"] == rr.RES_SHA_SCAN, m assert m.get("skip_reason") != 26 assert "repo_root_hint" not in _read_state_or_empty(hook_env) def test_cwd_is_repo_dash_C_other_repo(self, workspace, hook_env, tmp_path): ws, repo = workspace other = make_repo(ws / "other") remote = tmp_path / "remote.git" git(ws, "init", "-q", "--bare", str(remote)) git(other, "remote", "add", "origin", str(remote)) git(other, "push", "-q", "-u", "origin", "main") base = git(other, "rev-parse", "HEAD").strip() sha, _ = commit_file(other, "srv.py", VULN_PY) git(other, "push", "-q", "origin", "main") push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n" rc, so, se = run_hook(bash_payload(repo, f"git -C {other} push origin main", push_stdout), hook_env) m = metrics_of(so) assert m["push_sweep"] is True and "cwd_is_repo" not in m assert m["repo_resolution"] == rr.RES_COMMAND and m.get("pushed") == 1 class TestStop: def _touch(self, ws, repo, hook_env, session_id="s1"): (repo / "app.py").write_text(VULN_PY) run_hook(edit_payload(ws, repo / "app.py", VULN_PY, session_id=session_id), hook_env) def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(repo), hook_env) self._touch(repo, repo, hook_env) rc, so, se = run_hook(stop_payload(repo), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert "repo_resolution" not in m and m["files_reviewed"] == 1 assert stub_api.calls def test_workspace_cwd_resolves_from_touched_paths(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(ws), hook_env) self._touch(ws, repo, hook_env) rc, so, se = run_hook(stop_payload(ws), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_TOUCHED_PATHS assert m["files_reviewed"] == 1 and m["review_set_count"] == 1 assert stub_api.calls def test_subagent_stop_reviews_without_consuming_session_state(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(ws), hook_env) self._touch(ws, repo, hook_env) before = _read_state(hook_env) rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["repo_resolution"] == rr.RES_TOUCHED_PATHS and m["files_reviewed"] == 1 after = _read_state(hook_env) assert after["touched_paths"] == before["touched_paths"] != [] assert after.get("baseline_sha") == before.get("baseline_sha") assert after.get("reviewed_diff_hash") assert "repo_root_hint" not in after n_calls = len(stub_api.calls) rc, so, se = run_hook(stop_payload(ws), hook_env) m2 = metrics_of(so) assert m2["skip_reason"] == 12, m2 assert m2["repo_resolution"] == rr.RES_TOUCHED_PATHS assert len(stub_api.calls) == n_calls final = _read_state(hook_env) assert final["touched_paths"] == [] and "reviewed_diff_hash" not in final def test_main_edits_during_subagent_review_are_reviewed_at_stop(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(repo), hook_env) self._touch(repo, repo, hook_env) stub_api.delay = 3 res = {} t = threading.Thread(target=lambda: res.update( sub=run_hook(stop_payload(repo, event="SubagentStop"), hook_env))) t.start() time.sleep(1.5) (repo / "b.py").write_text("import os\nos.system(input())\n") run_hook(edit_payload(repo, repo / "b.py", "x"), hook_env) t.join() stub_api.delay = 0 m_sub = metrics_of(res["sub"][1]) assert m_sub.get("skip_reason") is None and m_sub["files_reviewed"] == 1, m_sub n_calls = len(stub_api.calls) rc, so, se = run_hook(stop_payload(repo), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None, m assert m["files_reviewed"] == 2 and m["touched_paths_count"] == 2 assert len(stub_api.calls) > n_calls def test_subagent_stop_findings_do_not_advance_baseline_or_fire_count(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(repo), hook_env) self._touch(repo, repo, hook_env) before = _read_state(hook_env) stub_api.vulns = [STUB_VULN] rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), hook_env) m = metrics_of(so) assert rc == 2 and m["vulns_found"] == 1, (rc, m) assert "repo_resolution" not in m and "cwd_is_repo" not in m after = _read_state(hook_env) assert after.get("baseline_sha") == before.get("baseline_sha") assert not after.get("stop_hook_fire_count") assert after["touched_paths"] == before["touched_paths"] assert len(after.get("previous_findings", [])) == 1 and after.get("reviewed_diff_hash") rc, so, se = run_hook(stop_payload(repo), hook_env) assert metrics_of(so)["skip_reason"] == 12 (repo / "app.py").write_text(VULN_PY + "x = 1\n") run_hook(edit_payload(repo, repo / "app.py", "x"), hook_env) rc, so, se = run_hook(stop_payload(repo), hook_env) m3 = metrics_of(so) assert rc == 2 and m3.get("skip_reason") is None and m3["files_reviewed"] == 1, m3 assert _read_state(hook_env)["stop_hook_fire_count"] == 1 def test_subagent_stop_in_other_worktree_is_skipped(self, workspace, hook_env, stub_api, tmp_path): ws, repo = workspace wt = tmp_path / "wt" git(repo, "worktree", "add", "-q", "-b", "agent", str(wt)) run_hook(ups_payload(repo), hook_env) self._touch(repo, repo, hook_env) before = _read_state(hook_env) (wt / "new.py").write_text("import pickle\npickle.loads(b)\n") run_hook(edit_payload(wt, wt / "new.py", "x"), hook_env) env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)} rc, so, se = run_hook(stop_payload(wt, event="SubagentStop"), env) m = metrics_of(so) assert m["skip_reason"] == 11, m assert not stub_api.calls after = _read_state(hook_env) assert after.get("baseline_sha") == before.get("baseline_sha") assert after.get("head_at_capture") == before.get("head_at_capture") rc, so, se = run_hook(stop_payload(repo), env) m2 = metrics_of(so) assert m2.get("skip_reason") is None and m2["files_reviewed"] == 1, m2 def test_subagent_stop_in_other_worktree_from_workspace_cwd(self, workspace, hook_env, stub_api, tmp_path): ws, repo = workspace wt = tmp_path / "wt" git(repo, "worktree", "add", "-q", "-b", "agent", str(wt)) run_hook(ups_payload(ws), hook_env) (wt / "new.py").write_text("import pickle\npickle.loads(b)\n") run_hook(edit_payload(ws, wt / "new.py", "x"), hook_env) env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)} rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), env) m = metrics_of(so) assert m["skip_reason"] == 11 and m["repo_resolution"] == rr.RES_TOUCHED_PATHS, m assert not stub_api.calls assert "repo_root_hint" not in _read_state(hook_env) def test_repository_config_cannot_run_programs(self, workspace, hook_env, stub_api, tmp_path): ws, repo = workspace marker = tmp_path / "marker" mon = tmp_path / "mon.sh" mon.write_text(f"#!/bin/sh\necho ran >> '{marker}'\n") mon.chmod(0o755) git(repo, "config", "core.fsmonitor", str(mon)) clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")} subprocess.run(["git", "status"], cwd=repo, env={**clean, **GIT_ENV}, capture_output=True) assert marker.exists() marker.unlink() run_hook(ups_payload(ws), hook_env) self._touch(ws, repo, hook_env) rc, so, se = run_hook(stop_payload(ws), hook_env) m = metrics_of(so) assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m assert not marker.exists() sha, out = commit_file(repo, "app.py", VULN_PY + "z = 3\n") marker.unlink(missing_ok=True) rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env) assert metrics_of(so).get("files_reviewed") == 1 assert not marker.exists() def test_safe_git_env_extends_existing_config_count(self): base = {"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "a.b", "GIT_CONFIG_VALUE_0": "1", "GIT_CONFIG_KEY_1": "c.d", "GIT_CONFIG_VALUE_1": "2"} env = gitutil.git_config_env(gitutil.SAFE_GIT_CONFIG, base=base) assert env["GIT_CONFIG_COUNT"] == str(2 + len(gitutil.SAFE_GIT_CONFIG)) assert "GIT_CONFIG_KEY_0" not in env and "GIT_CONFIG_KEY_1" not in env got = {env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"] for i in range(2, int(env["GIT_CONFIG_COUNT"]))} assert got == dict(gitutil.SAFE_GIT_CONFIG) assert gitutil.git_config_env((("x.y", "z"),), base={"GIT_CONFIG_COUNT": "junk"})["GIT_CONFIG_COUNT"] == "1" def test_subagent_stop_same_repo_with_project_dir_reviews(self, workspace, hook_env, stub_api): ws, repo = workspace (repo / "pkg").mkdir() run_hook(ups_payload(repo), hook_env) self._touch(repo, repo, hook_env) env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)} rc, so, se = run_hook(stop_payload(repo / "pkg", event="SubagentStop"), env) m = metrics_of(so) assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m env_ws = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)} (repo / "app.py").write_text(VULN_PY + "y = 2\n") rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), env_ws) assert metrics_of(so).get("skip_reason") is None def test_ups_uses_hint_for_baseline(self, workspace, hook_env): ws, repo = workspace run_hook(ups_payload(ws), hook_env) self._touch(ws, repo, hook_env) run_hook(stop_payload(ws), hook_env) (repo / "app.py").write_text(VULN_PY + "\n# more\n") run_hook(ups_payload(ws, session_id="s1"), hook_env) state = _read_state(hook_env) assert state.get("baseline_sha") and state.get("repo_root_hint") == str(repo) def test_workspace_cwd_nothing_touched_skips(self, workspace, hook_env, stub_api): ws, repo = workspace run_hook(ups_payload(ws), hook_env) rc, so, se = run_hook(stop_payload(ws), hook_env) m = metrics_of(so) assert m["skip_reason"] == 9 and m["repo_resolution"] == rr.RES_NONE assert not stub_api.calls