test_repo_resolution.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import json
  2. import os
  3. import subprocess
  4. import threading
  5. import time
  6. import pytest
  7. from conftest import (
  8. GIT_ENV, HOOKS_DIR, STUB_VULN, VULN_PY, bash_payload, commit_file, edit_payload,
  9. git, make_repo, metrics_of, run_hook, stop_payload, ups_payload,
  10. )
  11. import gitutil
  12. import reporesolve as rr
  13. import security_reminder_hook as hook
  14. class TestDirsFromCommand:
  15. def test_cd_and_and(self, workspace):
  16. ws, repo = workspace
  17. assert rr.dirs_from_command("cd sub && git commit -m x", str(ws)) == [str(repo)]
  18. def test_cd_semicolon_subshell(self, workspace):
  19. ws, repo = workspace
  20. assert rr.dirs_from_command("(cd sub; git commit -m x)", str(ws)) == [str(repo)]
  21. def test_git_dash_C(self, workspace):
  22. ws, repo = workspace
  23. assert rr.dirs_from_command("git -C sub commit -q -m x", str(ws)) == [str(repo)]
  24. def test_git_dash_C_absolute_and_quoted(self, tmp_path):
  25. d = tmp_path / "my repo"
  26. d.mkdir()
  27. assert rr.dirs_from_command(f'git -C "{d}" commit -m "a b"', "/nonexistent") == [str(d)]
  28. def test_env_prefix_and_config_opts(self, workspace):
  29. ws, repo = workspace
  30. cmd = "FOO=1 git -c user.name=x -C sub commit -m x"
  31. assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
  32. def test_git_dir_work_tree(self, workspace):
  33. ws, repo = workspace
  34. cmd = "git --git-dir=sub/.git --work-tree=sub commit -m x"
  35. assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
  36. cmd = "git --git-dir sub/.git commit -m x"
  37. assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
  38. def test_subcommand_filter(self, workspace):
  39. ws, repo = workspace
  40. cmd = "git -C other status && git -C sub commit -m x && git -C third push"
  41. assert rr.dirs_from_command(cmd, str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
  42. assert rr.dirs_from_command(cmd, str(ws), rr.PUSH_SUBCOMMANDS) == [str(ws / "third")]
  43. def test_gt(self, workspace):
  44. ws, repo = workspace
  45. assert rr.dirs_from_command("cd sub && gt create -m x", str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
  46. def test_pushd_and_relative_chain(self, workspace):
  47. ws, repo = workspace
  48. cmd = "pushd sub && cd .. && cd ./sub && git commit -m x"
  49. assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
  50. @pytest.mark.parametrize("cmd", [
  51. 'git -C "unterminated commit -m x',
  52. "git -C=sub commit -m x",
  53. "cd && git commit",
  54. "cd - && git commit -m x",
  55. "git",
  56. "git -C",
  57. "cd /definitely/not/here && git commit -m x",
  58. "echo 'git commit' | cat",
  59. "git commit -m \"$(cat <<'EOF'\nmsg\nEOF\n)\"",
  60. "",
  61. None,
  62. ])
  63. def test_odd_inputs_do_not_raise(self, workspace, cmd):
  64. ws, _ = workspace
  65. out = rr.dirs_from_command(cmd, str(ws))
  66. assert isinstance(out, list)
  67. assert rr.toplevel_from_command(cmd, str(ws)) in (None, str(ws / "sub"))
  68. def test_toplevel_from_command_nonexistent_dir(self, workspace):
  69. ws, _ = workspace
  70. assert rr.toplevel_from_command("cd nope && git commit -m x", str(ws)) is None
  71. def test_toplevel_from_subdir_of_repo(self, workspace):
  72. ws, repo = workspace
  73. (repo / "pkg").mkdir()
  74. assert rr.toplevel_from_command("cd sub/pkg && git commit -m x", str(ws)) == str(repo)
  75. def test_windows_backslash_paths_survive_tokenizing(self, workspace, monkeypatch):
  76. ws, _ = workspace
  77. monkeypatch.setattr(rr.os, "sep", "\\")
  78. toks = rr._tokenize(r'git -C C:\Users\me\repo commit -m x && git -C "D:\a b\r" push')
  79. assert r"C:\Users\me\repo" in toks and r"D:\a b\r" in toks
  80. toks = rr._tokenize(r"git -C \\srv\share\repo commit -m x")
  81. assert r"\\srv\share\repo" in toks
  82. class TestShaScan:
  83. def test_finds_repo_containing_commit(self, workspace):
  84. ws, repo = workspace
  85. sha, _ = commit_file(repo, "app.py", VULN_PY)
  86. assert rr.repo_containing_commit(sha[:7], [str(ws)]) == str(repo)
  87. assert rr.repo_containing_commit(sha, [str(ws)]) == str(repo)
  88. def test_unknown_sha(self, workspace):
  89. ws, _ = workspace
  90. assert rr.repo_containing_commit("deadbeefdeadbeef", [str(ws)]) is None
  91. def test_invalid_sha(self, workspace):
  92. ws, _ = workspace
  93. assert rr.repo_containing_commit("HEAD; rm -rf x", [str(ws)]) is None
  94. assert rr.repo_containing_commit(None, [str(ws)]) is None
  95. def test_skips_ignored_dirs_and_respects_depth(self, tmp_path):
  96. ws = tmp_path / "ws"
  97. deep = make_repo(ws / "a" / "b" / "c" / "d" / "repo")
  98. nm = make_repo(ws / "node_modules" / "pkg")
  99. found = list(rr.iter_git_repos([str(ws)], max_depth=3))
  100. assert str(deep) not in found and str(nm) not in found
  101. found = list(rr.iter_git_repos([str(ws)], max_depth=6))
  102. assert str(deep) in found and str(nm) not in found
  103. def test_max_repos_cap(self, tmp_path):
  104. ws = tmp_path / "ws"
  105. for i in range(4):
  106. make_repo(ws / f"r{i}")
  107. assert len(list(rr.iter_git_repos([str(ws)], max_repos=2))) == 2
  108. class TestReposFromPaths:
  109. def test_groups_by_toplevel_and_orders_by_count(self, tmp_path):
  110. ws = tmp_path / "ws"
  111. a = make_repo(ws / "a")
  112. b = make_repo(ws / "b")
  113. paths = [str(b / "x.py"), str(a / "one.py"), str(a / "pkg" / "two.py"),
  114. str(ws / "loose.txt"), "", None, 42]
  115. assert rr.repos_from_paths(paths, str(ws)) == [str(a), str(b)]
  116. def test_relative_paths_resolve_against_cwd(self, workspace):
  117. ws, repo = workspace
  118. assert rr.repos_from_paths(["sub/app.py"], str(ws)) == [str(repo)]
  119. def test_missing_parent_dirs(self, workspace):
  120. ws, repo = workspace
  121. assert rr.repos_from_paths([str(repo / "new" / "deeper" / "f.py")], str(ws)) == [str(repo)]
  122. class TestResolveRepoRoot:
  123. def test_cwd_is_repo(self, workspace):
  124. ws, repo = workspace
  125. assert rr.resolve_repo_root(str(repo), "cd /tmp && git commit") == (str(repo), rr.RES_CWD)
  126. def test_cwd_is_repo_same_repo_in_command_stays_cwd(self, workspace):
  127. ws, repo = workspace
  128. (repo / "pkg").mkdir()
  129. for cmd in ("git commit -m x", "cd pkg && git commit -m x", f"git -C {repo} commit -m x",
  130. "git -C pkg commit -m x", "git -C nope commit -m x"):
  131. assert rr.resolve_repo_root(str(repo), cmd, rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD), cmd
  132. def test_explicit_other_repo_in_command_beats_cwd(self, workspace):
  133. ws, repo = workspace
  134. other = make_repo(ws / "other")
  135. assert rr.resolve_repo_root(str(repo), f"git -C {other} commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
  136. assert rr.resolve_repo_root(str(repo), "git -C ../other push", rr.PUSH_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
  137. assert rr.resolve_repo_root(str(repo), "cd ../other && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
  138. 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)
  139. assert rr.resolve_repo_root(str(repo), f"git -C {other} status && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD)
  140. def test_order_command_then_paths_then_sha(self, workspace):
  141. ws, repo = workspace
  142. other = make_repo(ws / "other")
  143. sha, _ = commit_file(repo, "app.py", VULN_PY)
  144. assert rr.resolve_repo_root(str(ws), "git -C other commit", rr.COMMIT_SUBCOMMANDS,
  145. sha=sha, touched_paths=[str(repo / "app.py")]) == (str(other), rr.RES_COMMAND)
  146. assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
  147. sha=sha, touched_paths=[str(repo / "app.py")]) == (str(repo), rr.RES_TOUCHED_PATHS)
  148. assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
  149. sha=sha) == (str(repo), rr.RES_SHA_SCAN)
  150. assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
  151. class TestRegexes:
  152. @pytest.mark.parametrize("cmd", [
  153. "git commit -m x",
  154. "git -C sub commit -m x",
  155. 'git -C "a b" commit -m x',
  156. "git -c core.editor=true -C sub commit --amend",
  157. "git --git-dir=x/.git --work-tree=x commit -m x",
  158. "git --git-dir x/.git commit -m x",
  159. "cd sub && git commit -m x",
  160. "gt create -m x",
  161. ])
  162. def test_commit_re(self, cmd):
  163. assert hook._GIT_COMMIT_RE.search(cmd)
  164. @pytest.mark.parametrize("cmd", [
  165. "git push", "git -C sub push origin main", 'git -C "a b" push',
  166. "git --work-tree x push -u origin HEAD", "gt submit",
  167. ])
  168. def test_push_re(self, cmd):
  169. assert hook._GIT_PUSH_RE.search(cmd)
  170. @pytest.mark.parametrize("cmd", ["git status", "git log --oneline", "echo commit"])
  171. def test_commit_re_negative(self, cmd):
  172. assert not hook._GIT_COMMIT_RE.search(cmd)
  173. @pytest.mark.parametrize("unit", ['-c "a"', "-c 'a'", "--no-a=b", "--a=b=c=d", "--git-dir x"])
  174. @pytest.mark.parametrize("regex,verb", [("_GIT_COMMIT_RE", "commit"), ("_GIT_PUSH_RE", "push")])
  175. def test_global_option_prefix_is_linear(self, regex, verb, unit):
  176. cre = getattr(hook, regex)
  177. prefix = "git " + " ".join([unit] * 40)
  178. t0 = time.perf_counter()
  179. assert not cre.search(prefix + " " + verb + "foo")
  180. assert time.perf_counter() - t0 < 0.05
  181. assert cre.search(prefix + " " + verb + " -m x")
  182. class TestHooksJson:
  183. def test_matchers_and_events(self):
  184. cfg = json.loads((HOOKS_DIR / "hooks.json").read_text())
  185. assert "SubagentStop" in cfg["hooks"]
  186. assert cfg["hooks"]["SubagentStop"][0]["hooks"][0]["command"] == \
  187. cfg["hooks"]["Stop"][0]["hooks"][0]["command"]
  188. bash = [g for g in cfg["hooks"]["PostToolUse"] if g.get("matcher") == "Bash"][0]
  189. ifs = {h.get("if") for h in bash["hooks"]}
  190. assert {"Bash(git commit:*)", "Bash(git push:*)", "Bash(git -C * commit *)",
  191. "Bash(git -C * push*)", "Bash(gt create:*)", "Bash(gt modify:*)",
  192. "Bash(gt submit:*)"} <= ifs
  193. def _read_state(env):
  194. d = env["SECURITY_WARNINGS_STATE_DIR"]
  195. files = [e.path for e in os.scandir(d) if e.name.endswith(".json")]
  196. assert files, os.listdir(d)
  197. with open(files[0]) as f:
  198. return json.load(f)
  199. def _read_state_or_empty(env):
  200. d = env["SECURITY_WARNINGS_STATE_DIR"]
  201. if not any(e.name.endswith(".json") for e in os.scandir(d)):
  202. return {}
  203. return _read_state(env)
  204. class TestCommitReview:
  205. def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
  206. ws, repo = workspace
  207. sha, out = commit_file(repo, "app.py", VULN_PY)
  208. rc, so, se = run_hook(bash_payload(repo, "git commit -m change", out), hook_env)
  209. m = metrics_of(so)
  210. assert m["commit_review"] is True
  211. assert "cwd_is_repo" not in m and "repo_resolution" not in m
  212. assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
  213. assert stub_api.calls
  214. def test_cwd_is_repo_cd_subdir_unchanged(self, workspace, hook_env, stub_api):
  215. ws, repo = workspace
  216. (repo / "pkg").mkdir()
  217. sha, out = commit_file(repo, "pkg/app.py", VULN_PY)
  218. rc, so, se = run_hook(bash_payload(repo, "cd pkg && git commit -m change", out), hook_env)
  219. m = metrics_of(so)
  220. assert "cwd_is_repo" not in m and "repo_resolution" not in m
  221. assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
  222. def test_cwd_is_repo_dash_C_other_repo_reviews_other_repo(self, workspace, hook_env, stub_api):
  223. ws, repo = workspace
  224. other = make_repo(ws / "other")
  225. sha, out = commit_file(other, "srv.py", VULN_PY)
  226. rc, so, se = run_hook(bash_payload(repo, f"git -C {other} commit -m change", out), hook_env)
  227. m = metrics_of(so)
  228. assert m.get("skip_reason") is None, m
  229. assert "cwd_is_repo" not in m and m["repo_resolution"] == rr.RES_COMMAND
  230. assert m["files_reviewed"] == 1
  231. assert stub_api.calls
  232. assert (other / ".git" / "sg-reviewed-shas").exists()
  233. assert not (repo / ".git" / "sg-reviewed-shas").exists()
  234. def test_workspace_cwd_cd_sub(self, workspace, hook_env, stub_api):
  235. ws, repo = workspace
  236. sha, out = commit_file(repo, "app.py", VULN_PY)
  237. rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
  238. m = metrics_of(so)
  239. assert m.get("skip_reason") is None, m
  240. assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
  241. assert m["files_reviewed"] == 1
  242. assert stub_api.calls
  243. def test_workspace_cwd_git_dash_C_quiet_uses_reflog(self, workspace, hook_env, stub_api):
  244. ws, repo = workspace
  245. sha, _ = commit_file(repo, "app.py", VULN_PY)
  246. rc, so, se = run_hook(bash_payload(ws, "git -C sub commit -q -m change", ""), hook_env)
  247. m = metrics_of(so)
  248. assert m.get("skip_reason") is None, m
  249. assert m["repo_resolution"] == rr.RES_COMMAND and m["sha_via_reflog"] is True
  250. assert m["files_reviewed"] == 1
  251. def test_workspace_cwd_sha_scan(self, workspace, hook_env, stub_api):
  252. ws, repo = workspace
  253. sha, out = commit_file(repo, "app.py", VULN_PY)
  254. rc, so, se = run_hook(bash_payload(ws, "git commit -m change", out), hook_env)
  255. m = metrics_of(so)
  256. assert m.get("skip_reason") is None, m
  257. assert m["repo_resolution"] == rr.RES_SHA_SCAN and m["files_reviewed"] == 1
  258. assert "repo_root_hint" not in _read_state_or_empty(hook_env)
  259. def test_workspace_cwd_sha_scan_via_project_dir(self, workspace, hook_env, tmp_path):
  260. ws, repo = workspace
  261. sha, out = commit_file(repo, "app.py", VULN_PY)
  262. elsewhere = tmp_path / "elsewhere"
  263. elsewhere.mkdir()
  264. env = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
  265. rc, so, se = run_hook(bash_payload(elsewhere, "git commit -m change", out), env)
  266. m = metrics_of(so)
  267. assert m.get("skip_reason") is None, m
  268. assert m["repo_resolution"] == rr.RES_SHA_SCAN
  269. def test_hint_saved_and_used(self, workspace, hook_env):
  270. ws, repo = workspace
  271. sha, out = commit_file(repo, "app.py", VULN_PY)
  272. run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
  273. state = _read_state(hook_env)
  274. assert state.get("repo_root_hint") == str(repo)
  275. assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
  276. os.environ["SECURITY_WARNINGS_STATE_DIR"] = hook_env["SECURITY_WARNINGS_STATE_DIR"]
  277. try:
  278. assert rr.load_repo_hint("s1") == str(repo)
  279. finally:
  280. os.environ.pop("SECURITY_WARNINGS_STATE_DIR", None)
  281. def test_hint_used_for_quiet_commit_without_dir(self, workspace, hook_env):
  282. ws, repo = workspace
  283. sha, out = commit_file(repo, "app.py", VULN_PY)
  284. run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
  285. commit_file(repo, "app.py", VULN_PY + "# 2\n")
  286. rc, so, se = run_hook(bash_payload(ws, "git commit -q -m change", ""), hook_env)
  287. m = metrics_of(so)
  288. assert m.get("skip_reason") is None, m
  289. assert m["repo_resolution"] == rr.RES_HINT and m["sha_via_reflog"] is True
  290. def test_unresolvable_still_skips_26(self, workspace, hook_env, tmp_path):
  291. ws, repo = workspace
  292. sha, out = commit_file(repo, "app.py", VULN_PY)
  293. empty = tmp_path / "empty"
  294. empty.mkdir()
  295. rc, so, se = run_hook(bash_payload(empty, "git commit -m change", out), hook_env)
  296. m = metrics_of(so)
  297. assert m["skip_reason"] == 26 and m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_NONE
  298. def test_no_credentials_gate_precedes_repo_resolution(self, workspace, hook_env):
  299. ws, repo = workspace
  300. sha, out = commit_file(repo, "app.py", VULN_PY)
  301. env = {k: v for k, v in hook_env.items() if k != "ANTHROPIC_API_KEY"}
  302. rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), env)
  303. m = metrics_of(so)
  304. assert m["skip_reason"] == 22 and m["repo_resolution"] == rr.RES_COMMAND
  305. def test_dedup_sentinel_uses_resolved_repo(self, workspace, hook_env):
  306. ws, repo = workspace
  307. sha, out = commit_file(repo, "app.py", VULN_PY)
  308. p = bash_payload(ws, "git -C sub commit -m change && git -C sub push", out, tool_use_id="toolu_1")
  309. rc1, so1, _ = run_hook(p, hook_env)
  310. rc2, so2, _ = run_hook(p, hook_env)
  311. assert metrics_of(so1).get("commit_review") is True
  312. assert metrics_of(so2) == {"bash_hook_dedup": True}
  313. class TestPushSweep:
  314. def test_workspace_cwd_git_dash_C_push(self, workspace, hook_env, tmp_path):
  315. ws, repo = workspace
  316. remote = tmp_path / "remote.git"
  317. git(ws, "init", "-q", "--bare", str(remote))
  318. git(repo, "remote", "add", "origin", str(remote))
  319. git(repo, "push", "-q", "-u", "origin", "main")
  320. base = git(repo, "rev-parse", "HEAD").strip()
  321. sha, _ = commit_file(repo, "app.py", VULN_PY)
  322. out = git(repo, "push", "--porcelain", "origin", "main")
  323. push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
  324. rc, so, se = run_hook(bash_payload(ws, "git -C sub push origin main", push_stdout), hook_env)
  325. m = metrics_of(so)
  326. assert m["push_sweep"] is True
  327. assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
  328. assert m.get("skip_reason") != 26
  329. assert m.get("pushed") == 1
  330. def test_workspace_cwd_sha_scan_push_leaves_no_hint(self, workspace, hook_env, tmp_path):
  331. ws, repo = workspace
  332. remote = tmp_path / "remote.git"
  333. git(ws, "init", "-q", "--bare", str(remote))
  334. git(repo, "remote", "add", "origin", str(remote))
  335. git(repo, "push", "-q", "-u", "origin", "main")
  336. base = git(repo, "rev-parse", "HEAD").strip()
  337. sha, _ = commit_file(repo, "app.py", VULN_PY)
  338. git(repo, "push", "-q", "origin", "main")
  339. push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
  340. rc, so, se = run_hook(bash_payload(ws, "git push origin main", push_stdout), hook_env)
  341. m = metrics_of(so)
  342. assert m["push_sweep"] is True and m["repo_resolution"] == rr.RES_SHA_SCAN, m
  343. assert m.get("skip_reason") != 26
  344. assert "repo_root_hint" not in _read_state_or_empty(hook_env)
  345. def test_cwd_is_repo_dash_C_other_repo(self, workspace, hook_env, tmp_path):
  346. ws, repo = workspace
  347. other = make_repo(ws / "other")
  348. remote = tmp_path / "remote.git"
  349. git(ws, "init", "-q", "--bare", str(remote))
  350. git(other, "remote", "add", "origin", str(remote))
  351. git(other, "push", "-q", "-u", "origin", "main")
  352. base = git(other, "rev-parse", "HEAD").strip()
  353. sha, _ = commit_file(other, "srv.py", VULN_PY)
  354. git(other, "push", "-q", "origin", "main")
  355. push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
  356. rc, so, se = run_hook(bash_payload(repo, f"git -C {other} push origin main", push_stdout), hook_env)
  357. m = metrics_of(so)
  358. assert m["push_sweep"] is True and "cwd_is_repo" not in m
  359. assert m["repo_resolution"] == rr.RES_COMMAND and m.get("pushed") == 1
  360. class TestStop:
  361. def _touch(self, ws, repo, hook_env, session_id="s1"):
  362. (repo / "app.py").write_text(VULN_PY)
  363. run_hook(edit_payload(ws, repo / "app.py", VULN_PY, session_id=session_id), hook_env)
  364. def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
  365. ws, repo = workspace
  366. run_hook(ups_payload(repo), hook_env)
  367. self._touch(repo, repo, hook_env)
  368. rc, so, se = run_hook(stop_payload(repo), hook_env)
  369. m = metrics_of(so)
  370. assert m.get("skip_reason") is None, m
  371. assert "repo_resolution" not in m and m["files_reviewed"] == 1
  372. assert stub_api.calls
  373. def test_workspace_cwd_resolves_from_touched_paths(self, workspace, hook_env, stub_api):
  374. ws, repo = workspace
  375. run_hook(ups_payload(ws), hook_env)
  376. self._touch(ws, repo, hook_env)
  377. rc, so, se = run_hook(stop_payload(ws), hook_env)
  378. m = metrics_of(so)
  379. assert m.get("skip_reason") is None, m
  380. assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_TOUCHED_PATHS
  381. assert m["files_reviewed"] == 1 and m["review_set_count"] == 1
  382. assert stub_api.calls
  383. def test_subagent_stop_reviews_without_consuming_session_state(self, workspace, hook_env, stub_api):
  384. ws, repo = workspace
  385. run_hook(ups_payload(ws), hook_env)
  386. self._touch(ws, repo, hook_env)
  387. before = _read_state(hook_env)
  388. rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), hook_env)
  389. m = metrics_of(so)
  390. assert m.get("skip_reason") is None, m
  391. assert m["repo_resolution"] == rr.RES_TOUCHED_PATHS and m["files_reviewed"] == 1
  392. after = _read_state(hook_env)
  393. assert after["touched_paths"] == before["touched_paths"] != []
  394. assert after.get("baseline_sha") == before.get("baseline_sha")
  395. assert after.get("reviewed_diff_hash")
  396. assert "repo_root_hint" not in after
  397. n_calls = len(stub_api.calls)
  398. rc, so, se = run_hook(stop_payload(ws), hook_env)
  399. m2 = metrics_of(so)
  400. assert m2["skip_reason"] == 12, m2
  401. assert m2["repo_resolution"] == rr.RES_TOUCHED_PATHS
  402. assert len(stub_api.calls) == n_calls
  403. final = _read_state(hook_env)
  404. assert final["touched_paths"] == [] and "reviewed_diff_hash" not in final
  405. def test_main_edits_during_subagent_review_are_reviewed_at_stop(self, workspace, hook_env, stub_api):
  406. ws, repo = workspace
  407. run_hook(ups_payload(repo), hook_env)
  408. self._touch(repo, repo, hook_env)
  409. stub_api.delay = 3
  410. res = {}
  411. t = threading.Thread(target=lambda: res.update(
  412. sub=run_hook(stop_payload(repo, event="SubagentStop"), hook_env)))
  413. t.start()
  414. time.sleep(1.5)
  415. (repo / "b.py").write_text("import os\nos.system(input())\n")
  416. run_hook(edit_payload(repo, repo / "b.py", "x"), hook_env)
  417. t.join()
  418. stub_api.delay = 0
  419. m_sub = metrics_of(res["sub"][1])
  420. assert m_sub.get("skip_reason") is None and m_sub["files_reviewed"] == 1, m_sub
  421. n_calls = len(stub_api.calls)
  422. rc, so, se = run_hook(stop_payload(repo), hook_env)
  423. m = metrics_of(so)
  424. assert m.get("skip_reason") is None, m
  425. assert m["files_reviewed"] == 2 and m["touched_paths_count"] == 2
  426. assert len(stub_api.calls) > n_calls
  427. def test_subagent_stop_findings_do_not_advance_baseline_or_fire_count(self, workspace, hook_env, stub_api):
  428. ws, repo = workspace
  429. run_hook(ups_payload(repo), hook_env)
  430. self._touch(repo, repo, hook_env)
  431. before = _read_state(hook_env)
  432. stub_api.vulns = [STUB_VULN]
  433. rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), hook_env)
  434. m = metrics_of(so)
  435. assert rc == 2 and m["vulns_found"] == 1, (rc, m)
  436. assert "repo_resolution" not in m and "cwd_is_repo" not in m
  437. after = _read_state(hook_env)
  438. assert after.get("baseline_sha") == before.get("baseline_sha")
  439. assert not after.get("stop_hook_fire_count")
  440. assert after["touched_paths"] == before["touched_paths"]
  441. assert len(after.get("previous_findings", [])) == 1 and after.get("reviewed_diff_hash")
  442. rc, so, se = run_hook(stop_payload(repo), hook_env)
  443. assert metrics_of(so)["skip_reason"] == 12
  444. (repo / "app.py").write_text(VULN_PY + "x = 1\n")
  445. run_hook(edit_payload(repo, repo / "app.py", "x"), hook_env)
  446. rc, so, se = run_hook(stop_payload(repo), hook_env)
  447. m3 = metrics_of(so)
  448. assert rc == 2 and m3.get("skip_reason") is None and m3["files_reviewed"] == 1, m3
  449. assert _read_state(hook_env)["stop_hook_fire_count"] == 1
  450. def test_subagent_stop_in_other_worktree_is_skipped(self, workspace, hook_env, stub_api, tmp_path):
  451. ws, repo = workspace
  452. wt = tmp_path / "wt"
  453. git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
  454. run_hook(ups_payload(repo), hook_env)
  455. self._touch(repo, repo, hook_env)
  456. before = _read_state(hook_env)
  457. (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
  458. run_hook(edit_payload(wt, wt / "new.py", "x"), hook_env)
  459. env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
  460. rc, so, se = run_hook(stop_payload(wt, event="SubagentStop"), env)
  461. m = metrics_of(so)
  462. assert m["skip_reason"] == 11, m
  463. assert not stub_api.calls
  464. after = _read_state(hook_env)
  465. assert after.get("baseline_sha") == before.get("baseline_sha")
  466. assert after.get("head_at_capture") == before.get("head_at_capture")
  467. rc, so, se = run_hook(stop_payload(repo), env)
  468. m2 = metrics_of(so)
  469. assert m2.get("skip_reason") is None and m2["files_reviewed"] == 1, m2
  470. def test_subagent_stop_in_other_worktree_from_workspace_cwd(self, workspace, hook_env, stub_api, tmp_path):
  471. ws, repo = workspace
  472. wt = tmp_path / "wt"
  473. git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
  474. run_hook(ups_payload(ws), hook_env)
  475. (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
  476. run_hook(edit_payload(ws, wt / "new.py", "x"), hook_env)
  477. env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
  478. rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), env)
  479. m = metrics_of(so)
  480. assert m["skip_reason"] == 11 and m["repo_resolution"] == rr.RES_TOUCHED_PATHS, m
  481. assert not stub_api.calls
  482. assert "repo_root_hint" not in _read_state(hook_env)
  483. def test_repository_config_cannot_run_programs(self, workspace, hook_env, stub_api, tmp_path):
  484. ws, repo = workspace
  485. marker = tmp_path / "marker"
  486. mon = tmp_path / "mon.sh"
  487. mon.write_text(f"#!/bin/sh\necho ran >> '{marker}'\n")
  488. mon.chmod(0o755)
  489. git(repo, "config", "core.fsmonitor", str(mon))
  490. clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")}
  491. subprocess.run(["git", "status"], cwd=repo, env={**clean, **GIT_ENV}, capture_output=True)
  492. assert marker.exists()
  493. marker.unlink()
  494. run_hook(ups_payload(ws), hook_env)
  495. self._touch(ws, repo, hook_env)
  496. rc, so, se = run_hook(stop_payload(ws), hook_env)
  497. m = metrics_of(so)
  498. assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
  499. assert not marker.exists()
  500. sha, out = commit_file(repo, "app.py", VULN_PY + "z = 3\n")
  501. marker.unlink(missing_ok=True)
  502. rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
  503. assert metrics_of(so).get("files_reviewed") == 1
  504. assert not marker.exists()
  505. def test_safe_git_env_extends_existing_config_count(self):
  506. base = {"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "a.b", "GIT_CONFIG_VALUE_0": "1",
  507. "GIT_CONFIG_KEY_1": "c.d", "GIT_CONFIG_VALUE_1": "2"}
  508. env = gitutil.git_config_env(gitutil.SAFE_GIT_CONFIG, base=base)
  509. assert env["GIT_CONFIG_COUNT"] == str(2 + len(gitutil.SAFE_GIT_CONFIG))
  510. assert "GIT_CONFIG_KEY_0" not in env and "GIT_CONFIG_KEY_1" not in env
  511. got = {env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"]
  512. for i in range(2, int(env["GIT_CONFIG_COUNT"]))}
  513. assert got == dict(gitutil.SAFE_GIT_CONFIG)
  514. assert gitutil.git_config_env((("x.y", "z"),), base={"GIT_CONFIG_COUNT": "junk"})["GIT_CONFIG_COUNT"] == "1"
  515. def test_subagent_stop_same_repo_with_project_dir_reviews(self, workspace, hook_env, stub_api):
  516. ws, repo = workspace
  517. (repo / "pkg").mkdir()
  518. run_hook(ups_payload(repo), hook_env)
  519. self._touch(repo, repo, hook_env)
  520. env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
  521. rc, so, se = run_hook(stop_payload(repo / "pkg", event="SubagentStop"), env)
  522. m = metrics_of(so)
  523. assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
  524. env_ws = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
  525. (repo / "app.py").write_text(VULN_PY + "y = 2\n")
  526. rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), env_ws)
  527. assert metrics_of(so).get("skip_reason") is None
  528. def test_ups_uses_hint_for_baseline(self, workspace, hook_env):
  529. ws, repo = workspace
  530. run_hook(ups_payload(ws), hook_env)
  531. self._touch(ws, repo, hook_env)
  532. run_hook(stop_payload(ws), hook_env)
  533. (repo / "app.py").write_text(VULN_PY + "\n# more\n")
  534. run_hook(ups_payload(ws, session_id="s1"), hook_env)
  535. state = _read_state(hook_env)
  536. assert state.get("baseline_sha") and state.get("repo_root_hint") == str(repo)
  537. def test_workspace_cwd_nothing_touched_skips(self, workspace, hook_env, stub_api):
  538. ws, repo = workspace
  539. run_hook(ups_payload(ws), hook_env)
  540. rc, so, se = run_hook(stop_payload(ws), hook_env)
  541. m = metrics_of(so)
  542. assert m["skip_reason"] == 9 and m["repo_resolution"] == rr.RES_NONE
  543. assert not stub_api.calls