gitutil.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  1. """
  2. Leaf git/subprocess helpers and diff parsing for the security-guidance plugin.
  3. Everything here is a thin wrapper over ``git``/``subprocess`` plus pure
  4. diff-text parsing and source-file classification. None of these functions
  5. reference any name that the test suite monkeypatches on
  6. ``security_reminder_hook`` and then calls *through* another function in this
  7. module — that property is what makes them safe to live in their own module
  8. while still being re-exported (so tests that patch ``hook._git_toplevel`` and
  9. then call a handler in ``security_reminder_hook`` continue to see the patched
  10. binding).
  11. Functions that DO compose patched leaves (``compute_v2_review_set``,
  12. ``_list_untracked``, ``_append_reviewed_shas``) deliberately remain in
  13. ``security_reminder_hook.py`` for that reason.
  14. """
  15. import contextlib
  16. import os
  17. import re
  18. import subprocess
  19. from _base import debug_log
  20. GIT_CMD = [
  21. "git",
  22. "-c", "core.fsmonitor=false",
  23. "-c", "core.hooksPath=/dev/null",
  24. # core.quotePath=false: emit raw UTF-8 in path-emitting commands instead
  25. # of C-quoting non-ASCII bytes (default `"\\303\\201vila/..."` vs
  26. # `Ávila/...`). Downstream parsers — both ours (parse_diff_into_files,
  27. # extract_file_paths_from_diff) and Python stdlib (os.path.isabs,
  28. # os.path.join) — expect raw paths and silently drop / mishandle the
  29. # quoted form. Adding the flag globally to GIT_CMD covers every
  30. # subprocess.run site that uses the splat — diff feeders, rev-parse
  31. # path queries (--show-toplevel, --git-dir, --git-common-dir),
  32. # reflog %gs subjects, ls-files, status, etc. — without per-site
  33. # flag duplication. See #2082, #2099.
  34. "-c", "core.quotePath=false",
  35. ]
  36. SAFE_GIT_CONFIG = (
  37. ("core.fsmonitor", "false"),
  38. ("core.hooksPath", "/dev/null"),
  39. )
  40. def git_config_env(pairs, base=None):
  41. base = os.environ if base is None else base
  42. try:
  43. n = max(0, int(base.get("GIT_CONFIG_COUNT") or 0))
  44. except (TypeError, ValueError):
  45. n = 0
  46. env = {}
  47. for i, (k, v) in enumerate(pairs, start=n):
  48. env[f"GIT_CONFIG_KEY_{i}"] = k
  49. env[f"GIT_CONFIG_VALUE_{i}"] = v
  50. env["GIT_CONFIG_COUNT"] = str(n + len(pairs))
  51. return env
  52. def apply_safe_git_env():
  53. os.environ.update(git_config_env(SAFE_GIT_CONFIG))
  54. def _git_rev_parse_head(cwd):
  55. """Return the current HEAD SHA, or None if not a git repo / no commits."""
  56. try:
  57. # See #2099: text=True on Windows cp1252 crashes the reader thread on
  58. # any UTF-8 byte undefined in cp1252 (e.g. via a git error message
  59. # referencing a non-ASCII filename in stderr). stdout is a SHA so it
  60. # IS safe; stderr is not. capture_output=True with bytes-by-default
  61. # never decodes, so the reader thread can't crash.
  62. result = subprocess.run(
  63. [*GIT_CMD, "rev-parse", "HEAD"],
  64. cwd=cwd, capture_output=True, timeout=5
  65. )
  66. if result.returncode == 0 and result.stdout.strip():
  67. return result.stdout.decode("utf-8", errors="replace").strip()
  68. return None
  69. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  70. return None
  71. def _find_git_index(cwd):
  72. """
  73. Find the real index file for a git repo. Handles worktrees where .git
  74. is a file pointing to the main repo's gitdir.
  75. Returns the absolute path to the index file, or None.
  76. """
  77. try:
  78. # See #2099: stdout here is a PATH which can contain non-ASCII bytes
  79. # (e.g. C:\אבטחה\repo\.git). text=True decodes via cp1252 strict on
  80. # Windows → crashes the reader thread → returns stdout=None →
  81. # caller does .strip() on None → AttributeError. Decode manually.
  82. result = subprocess.run(
  83. [*GIT_CMD, "rev-parse", "--git-dir"],
  84. cwd=cwd, capture_output=True, timeout=5
  85. )
  86. if result.returncode != 0:
  87. return None
  88. git_dir = result.stdout.decode("utf-8", errors="replace").strip()
  89. if not os.path.isabs(git_dir):
  90. git_dir = os.path.join(cwd, git_dir)
  91. index_path = os.path.join(git_dir, "index")
  92. return index_path if os.path.isfile(index_path) else None
  93. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  94. return None
  95. def _diff_pathspec(cwd, paths):
  96. """Convert absolute touched-paths to repo-relative pathspec args for
  97. git diff. Paths outside cwd (e.g. ~/.claude/…) are dropped. Returns the
  98. list to splice after `--`, or [] for an unrestricted diff. realpath both
  99. sides so the macOS /var ↔ /private/var symlink doesn't make in-repo
  100. paths look external."""
  101. if not paths:
  102. return []
  103. cwd_abs = os.path.realpath(cwd)
  104. rel = []
  105. for p in paths:
  106. try:
  107. r = os.path.relpath(os.path.realpath(p), cwd_abs)
  108. except ValueError:
  109. continue
  110. if r.startswith(".."):
  111. continue
  112. rel.append(r)
  113. return ["--"] + rel if rel else []
  114. @contextlib.contextmanager
  115. def _temp_index(cwd, untracked_paths=None):
  116. """Yield an env dict pointing GIT_INDEX_FILE at a throwaway copy of the
  117. repo's index with `git add --intent-to-add` applied, so untracked files
  118. show up in subsequent `git diff` calls without touching the user's real
  119. index. Yields None if no index can be found (bare repo / not a repo); the
  120. caller should fall back to a plain diff. Always cleans up the temp file.
  121. Perf: when `untracked_paths` is given, only those paths are added (O(n)
  122. in untracked count). The default `add -N .` stats every file in the
  123. worktree — slow in large repos vs fast targeted scan. v2 callers
  124. already know the untracked set from `git status --porcelain`, so they
  125. pass it; v1 keeps the whole-tree scan since it has no prior list."""
  126. import shutil
  127. import tempfile
  128. real_index = _find_git_index(cwd)
  129. if not real_index:
  130. yield None
  131. return
  132. tmp_fd, tmp_index = tempfile.mkstemp(prefix="security_hook_idx_")
  133. os.close(tmp_fd)
  134. try:
  135. shutil.copy2(real_index, tmp_index)
  136. env = {**os.environ, "GIT_INDEX_FILE": tmp_index}
  137. if untracked_paths is None:
  138. add_args = ["."]
  139. elif untracked_paths:
  140. # `git add -N -- a b nonexistent` is atomic — one missing path
  141. # makes it exit 128 and add NOTHING, so a file removed between
  142. # `git status` and here would silently drop ALL untracked files
  143. # from the diff. --ignore-missing only works with --dry-run, so
  144. # filter to surviving paths (lexists so dangling symlinks count).
  145. surviving = [p for p in untracked_paths
  146. if os.path.lexists(os.path.join(cwd, p))]
  147. add_args = ["--"] + surviving if surviving else None
  148. else:
  149. add_args = None
  150. if add_args:
  151. # No stdout used here (only returncode matters), but text=True
  152. # still spawns reader threads that decode stderr — git error
  153. # messages can reference non-ASCII filenames and crash on
  154. # cp1252. See #2099. Drop text=True so bytes stay raw.
  155. subprocess.run(
  156. [*GIT_CMD, "add", "--intent-to-add"] + add_args,
  157. cwd=cwd, capture_output=True, timeout=10,
  158. env=env,
  159. )
  160. yield env
  161. finally:
  162. try:
  163. os.unlink(tmp_index)
  164. except OSError:
  165. pass
  166. def _git_toplevel(cwd):
  167. """Absolute repo root for `cwd`, or None if not in a work tree."""
  168. try:
  169. # See #2099: stdout is a PATH — `C:\אבטחה\repo` returned as UTF-8
  170. # bytes by git. text=True would decode via cp1252 strict on Windows
  171. # → reader-thread crash. Decode manually with errors="replace".
  172. r = subprocess.run(
  173. [*GIT_CMD, "rev-parse", "--show-toplevel"],
  174. cwd=cwd, capture_output=True, timeout=5,
  175. )
  176. if r.returncode != 0:
  177. return None
  178. path = r.stdout.decode("utf-8", errors="replace").strip()
  179. return path if path else None
  180. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  181. return None
  182. def _git_dir(repo_root):
  183. """Absolute shared `.git` directory for repo_root.
  184. Uses `rev-parse --git-common-dir` so linked worktrees resolve to the
  185. SHARED gitdir, not the per-worktree `.git/worktrees/<name>/`. That way
  186. push-sweep's reviewed-shas record (and the bash-hook-once sentinel)
  187. is per-clone — a commit reviewed in one worktree counts as reviewed
  188. if a different worktree later pushes it. Returns None on failure so
  189. callers can degrade (push-sweep state is best-effort).
  190. """
  191. try:
  192. # See #2099: stdout is a PATH (shared gitdir), may be non-ASCII.
  193. # Decode bytes manually to avoid cp1252 reader-thread crash.
  194. r = subprocess.run(
  195. [*GIT_CMD, "rev-parse", "--git-common-dir"],
  196. cwd=repo_root, capture_output=True, timeout=5,
  197. )
  198. if r.returncode != 0:
  199. return None
  200. d = r.stdout.decode("utf-8", errors="replace").strip()
  201. return d if os.path.isabs(d) else os.path.join(repo_root, d)
  202. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  203. return None
  204. def _git_rev_list_range(repo_root, base, head="HEAD"):
  205. """Shas in `base..head`, oldest→newest. Empty list on error."""
  206. try:
  207. # See #2099: stdout is ASCII SHAs, but stderr can carry git error
  208. # messages referencing non-ASCII filenames — keep bytes raw.
  209. r = subprocess.run(
  210. [*GIT_CMD, "rev-list", "--reverse", f"{base}..{head}"],
  211. cwd=repo_root, capture_output=True, timeout=10,
  212. )
  213. if r.returncode != 0:
  214. return []
  215. return [s for s in r.stdout.decode("utf-8", errors="replace").strip().split("\n") if s]
  216. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  217. return []
  218. def _git_diff_range(repo_root, base, head="HEAD"):
  219. """`git diff -p base head` as text on success, None on error.
  220. Distinguishing failure from success-with-empty-diff matters: the push-sweep
  221. caller marks the tail reviewed when the diff is empty (nothing to review),
  222. but on failure (timeout, non-zero exit, missing git) it must NOT mark
  223. them reviewed — otherwise unreviewed commits get permanently silenced.
  224. """
  225. try:
  226. # GIT_CMD globally passes core.quotePath=false (see definition) so
  227. # non-ASCII paths in `diff --git a/... b/...` headers come through as
  228. # raw UTF-8, not C-quoted. Required by the downstream
  229. # parse_diff_into_files / extract_file_paths_from_diff regex.
  230. r = subprocess.run(
  231. [*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head],
  232. cwd=repo_root, capture_output=True, timeout=30,
  233. )
  234. if r.returncode != 0:
  235. return None
  236. return r.stdout.decode("utf-8", errors="replace")
  237. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  238. return None
  239. def _detect_main_branch(repo_root):
  240. for ref in ("origin/HEAD", "origin/main", "origin/master", "main", "master"):
  241. try:
  242. # See #2099: stdout is a SHA but stderr can carry non-ASCII git
  243. # warnings — keep bytes raw to avoid cp1252 reader-thread crash.
  244. r = subprocess.run(
  245. [*GIT_CMD, "rev-parse", "--verify", "-q", ref],
  246. cwd=repo_root, capture_output=True, timeout=5,
  247. )
  248. if r.returncode == 0 and r.stdout.strip():
  249. return ref
  250. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  251. pass
  252. return None
  253. def _git_reflog_recent_commits(repo_root, max_age_s=120, max_n=5):
  254. """Return (fresh_commit_shas, stale_count) from the HEAD reflog.
  255. Scans the last `max_n` reflog entries and returns the SHAs whose action is
  256. `commit*` AND whose commit timestamp is within `max_age_s` of now,
  257. newest-first. `stale_count` is the number of commit-action entries that
  258. were too old (so the caller can distinguish "no commit happened" from
  259. "commit happened earlier than the window").
  260. Used by commit-review when stdout-based `[branch sha]` detection fails
  261. (output piped/redirected/-q, or a chained command after `git commit`
  262. pushed the success line off — `git commit && git push` makes HEAD@{0}
  263. `update by push`, not `commit:`). The HEAD@{0}-only check
  264. keeps the not-yet-visible-HEAD skip rare; analysis showed the
  265. residual is dominated by these chained-command and noop-guard cases.
  266. Safety vs. blindly reading HEAD:
  267. - cross-repo (`cd ../other && git commit`): repo_root's own reflog has
  268. no fresh commit, so this returns ([], 0).
  269. - commit actually failed (pre-commit reject, nothing-staged): reflog's
  270. recent entries are the prior checkout/commit/reset → ([], 0) or only
  271. stale entries.
  272. - HEAD raced ahead (a second commit landed before this async hook ran):
  273. both commits appear in the scan and both get reviewed — correct.
  274. - prior Bash call's commit within the window: would be returned here,
  275. but the call site deduplicates against `.git/sg-reviewed-shas` so a
  276. SHA is reviewed at most once. This is also the non-overlap invariant
  277. with push-sweep.
  278. """
  279. if not repo_root:
  280. return [], 0
  281. try:
  282. # %gs (the reflog subject) is `commit: <commit-msg first line>` and can
  283. # contain `|`; put it LAST so split("|", 2) leaves it intact. %H is
  284. # hex and %ct is integer, so the first two fields are delimiter-safe.
  285. #
  286. # Bytes + decode utf-8/replace: %gs embeds commit-message subjects
  287. # which git stores as raw bytes — commits can be authored in
  288. # latin-1 / cp1252 / shift-jis etc., and text=True would raise
  289. # UnicodeDecodeError in the subprocess reader thread on Windows
  290. # cp1252 (subprocess.run returns r.stdout=None, then
  291. # r.stdout.splitlines() AttributeErrors). Mirrors the existing
  292. # migration at security_reminder_hook.py:540 — same pattern was
  293. # missed here. See anthropics/claude-plugins-official#2056.
  294. r = subprocess.run(
  295. [*GIT_CMD, "log", "-g", "-n", str(max_n),
  296. "--format=%H|%ct|%gs", "HEAD"],
  297. cwd=repo_root, capture_output=True, timeout=5,
  298. )
  299. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
  300. return [], 0
  301. if r.returncode != 0:
  302. return [], 0
  303. stdout = (r.stdout or b"").decode("utf-8", errors="replace")
  304. import time as _time
  305. now = int(_time.time())
  306. fresh, stale = [], 0
  307. for idx, line in enumerate(stdout.splitlines()):
  308. parts = line.split("|", 2)
  309. if len(parts) != 3:
  310. continue
  311. sha, ct, subject = parts
  312. # `commit: msg`, `commit (amend): msg`, `commit (initial): msg`,
  313. # `commit (merge): msg` — all create a reviewable commit object.
  314. if not subject.startswith("commit"):
  315. continue
  316. try:
  317. age = now - int(ct)
  318. except ValueError:
  319. continue
  320. # HEAD@{0} (idx==0) is exempt from the age gate. The gate exists to
  321. # bound the WIDENED HEAD@{1..max_n-1} scan from picking up commits
  322. # made by *prior* Bash calls; HEAD@{0} is by definition the most
  323. # recent reflog entry and was previously accepted unconditionally
  324. # (_git_reflog_head_if_just_committed previously had no age check).
  325. # Applying max_age_s to idx==0 made the not-yet-visible-HEAD skip
  326. # noticeably more frequent on chained
  327. # `git commit && <slow command>` where %ct is >120s old by the
  328. # time the async PostToolUse hook fires.
  329. if idx == 0 or age <= max_age_s:
  330. fresh.append(sha)
  331. else:
  332. stale += 1
  333. return fresh, stale
  334. def _git_name_only(cwd, base, include_untracked=False):
  335. """Return the set of repo-root-relative paths that differ from `base`,
  336. or None if git failed (unresolvable ref, not a repo, timeout). Callers
  337. must distinguish None (error → don't trust as a filter) from set()
  338. (genuinely nothing changed). `-c core.quotePath=false -z` keeps non-ASCII
  339. and space-containing paths intact."""
  340. # Decode stdout/stderr as UTF-8 with errors="replace" instead of using
  341. # text=True. core.quotePath=false makes git emit raw UTF-8 for non-ASCII
  342. # paths, and text=True on Windows decodes via cp1252 strict — a non-ASCII
  343. # changed path would crash the subprocess reader thread, leave
  344. # result.stdout=None, and propagate AttributeError out of the helper.
  345. # Same fix shape as diffstate._list_untracked. See #2056.
  346. def _run(env):
  347. # core.quotePath=false comes from GIT_CMD globally (see definition).
  348. result = subprocess.run(
  349. [*GIT_CMD, "diff", "--name-only", "-z", base],
  350. cwd=cwd, capture_output=True, timeout=30,
  351. env=env,
  352. )
  353. if result.returncode != 0:
  354. stderr_str = (result.stderr or b"").decode("utf-8", errors="replace")
  355. debug_log(f"_git_name_only({base!r}) rc={result.returncode}: {stderr_str[:200]}")
  356. return None
  357. stdout = (result.stdout or b"").decode("utf-8", errors="replace")
  358. return {p for p in stdout.split("\0") if p}
  359. try:
  360. if not include_untracked:
  361. return _run(None)
  362. with _temp_index(cwd) as env:
  363. return _run(env)
  364. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  365. debug_log(f"_git_name_only({base!r}) error: {e}")
  366. return None
  367. def _git_status_porcelain(cwd):
  368. """One `git status --porcelain=v1 -z` → (tracked_dirty, untracked) sets of
  369. repo-root-relative paths, or (None, None) on error. Replaces the
  370. `_temp_index + git diff HEAD --name-only` pair for the v2 dirty_now
  371. computation: faster in large repos, and yields the
  372. untracked set separately so the later get_git_diff can do a targeted
  373. `add -N -- <files>` instead of a whole-tree `add -N .`.
  374. -uall: list individual files inside untracked directories (default
  375. collapses to `dir/`). Required so the untracked set subtracts cleanly
  376. against the UPS-time `_list_untracked` snapshot, which uses ls-files and
  377. therefore always lists individual files."""
  378. # Lenient decode: same UTF-8 + errors="replace" pattern as the
  379. # sibling helpers — a non-ASCII path in the worktree would otherwise
  380. # crash the cp1252 reader thread on Windows. See #2056.
  381. try:
  382. # core.quotePath=false comes from GIT_CMD globally (see definition).
  383. r = subprocess.run(
  384. [*GIT_CMD, "status", "--porcelain=v1", "-uall", "-z"],
  385. cwd=cwd, capture_output=True, timeout=30,
  386. )
  387. if r.returncode != 0:
  388. stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
  389. debug_log(f"_git_status_porcelain rc={r.returncode}: {stderr_str[:200]}")
  390. return None, None
  391. tracked, untracked = set(), set()
  392. stdout = (r.stdout or b"").decode("utf-8", errors="replace")
  393. entries = stdout.split("\0")
  394. i = 0
  395. while i < len(entries):
  396. e = entries[i]
  397. if not e:
  398. i += 1
  399. continue
  400. xy, path = e[:2], e[3:]
  401. if xy == "??":
  402. untracked.add(path)
  403. else:
  404. tracked.add(path)
  405. # Rename/copy entries are XY old\0new\0 — second NUL field is
  406. # the origin path; consume it so it isn't misparsed as a new
  407. # 2-char-status entry.
  408. if "R" in xy or "C" in xy:
  409. i += 1
  410. i += 1
  411. return tracked, untracked
  412. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  413. # ValueError guards against any future strict-decode regression
  414. # so the helper degrades to (None, None) instead of crashing.
  415. debug_log(f"_git_status_porcelain error: {e}")
  416. return None, None
  417. def _is_ancestor(cwd, maybe_ancestor, descendant):
  418. """True if `maybe_ancestor` is reachable from `descendant` (i.e. HEAD
  419. moved forward via commit/merge, not sideways via checkout)."""
  420. try:
  421. # See #2099: only returncode matters, but text=True spawns reader
  422. # threads that decode stderr — git error messages can carry non-ASCII
  423. # filenames. Drop text=True to keep bytes raw, avoid cp1252 crash.
  424. result = subprocess.run(
  425. [*GIT_CMD, "merge-base", "--is-ancestor", maybe_ancestor, descendant],
  426. cwd=cwd, capture_output=True, timeout=5,
  427. )
  428. return result.returncode == 0
  429. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  430. return False
  431. def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_paths=None):
  432. """
  433. Get the git diff between the baseline SHA and the current working tree,
  434. including untracked (new) files.
  435. Uses a temporary copy of the git index (GIT_INDEX_FILE) so the user's
  436. real index is never modified. The temp index gets intent-to-add entries
  437. for untracked files, making them visible in the diff output. Cleanup
  438. is just deleting the temp file in a finally block.
  439. If `paths` is given, the diff is restricted to those paths (relative to
  440. cwd; absolute paths are converted, paths outside cwd are dropped).
  441. `untracked_paths` (repo-root-relative) is forwarded to _temp_index so it
  442. can add only those files instead of scanning the whole worktree.
  443. """
  444. pathspec = _diff_pathspec(cwd, paths)
  445. if paths and not pathspec:
  446. # Caller restricted to specific paths but none are inside this repo
  447. # (e.g. only ~/.claude/... edits). Returning "" flows to skip(6); an
  448. # empty pathspec would mean an UNRESTRICTED diff — the bug this whole
  449. # change exists to fix.
  450. return ""
  451. # core.quotePath=false comes from GIT_CMD globally (see definition).
  452. cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
  453. try:
  454. with _temp_index(cwd, untracked_paths) as env:
  455. # env is None when no index could be found (bare repo / not a
  456. # repo) — diff still runs, just without untracked-file support.
  457. result = subprocess.run(cmd, cwd=cwd, capture_output=True, timeout=30, env=env)
  458. if result.returncode != 0:
  459. debug_log(f"git diff failed: {result.stderr[:200].decode('utf-8', errors='replace')}")
  460. return None
  461. # Decode with errors='replace' so binary diffs don't crash
  462. return result.stdout.decode("utf-8", errors="replace")
  463. except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
  464. debug_log(f"git diff error: {e}")
  465. return None
  466. # Source file extensions worth reviewing for security
  467. SOURCE_CODE_EXTENSIONS = {
  468. '.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.java', '.rb', '.php',
  469. '.rs', '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala',
  470. '.html', '.htm', '.ejs', '.yaml', '.yml', '.properties',
  471. '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
  472. '.sh', '.bash', '.zsh', '.fish', '.ksh', '.ps1', '.sql',
  473. '.gradle', '.groovy',
  474. '.tf', '.hcl', '.tfvars',
  475. '.json', '.toml', '.ipynb',
  476. }
  477. # Reviewable files identified by basename rather than extension (lowercased).
  478. # These are by-convention extensionless but contain executable recipes/DSL
  479. # with shell/exec surface (Make recipes, Jenkinsfile Groovy, Rakefile Ruby).
  480. SOURCE_CODE_BASENAMES = {
  481. 'dockerfile', 'makefile', 'gnumakefile', 'jenkinsfile', 'vagrantfile',
  482. 'rakefile', 'gemfile', 'procfile', 'brewfile', 'justfile',
  483. }
  484. # Extensionless basenames that are NOT source — plain-text metadata. Anything
  485. # extensionless not in this set is treated as source (likely a shebang script
  486. # under bin/ or scripts/). Analysis of skipped reviews found
  487. # extensionless executables (bin/deploy, scripts/run-canary) were the largest
  488. # remaining false-negative class — they carry shell-injection surface but
  489. # `splitext` gives '' so they were filtered out. _cap_files_for_prompt bounds
  490. # the byte cost downstream, and the reviewer ignores prose, so opting
  491. # extensionless IN with this small deny-list is the better default than
  492. # opting OUT.
  493. NON_SOURCE_EXTENSIONLESS_BASENAMES = {
  494. 'license', 'licence', 'copying', 'notice', 'patents', 'authors',
  495. 'contributors', 'maintainers', 'changelog', 'changes', 'news',
  496. 'readme', 'todo', 'install', 'version', 'codeowners',
  497. 'owners', 'copyright',
  498. }
  499. # Directory components and file suffixes that are never worth reviewing even
  500. # when the extension is in SOURCE_CODE_EXTENSIONS — vendored deps, build
  501. # output, generated code, minified bundles, lockfiles, protobuf stubs.
  502. # Matched as path *components* (so `node_modules/` matches anywhere in the
  503. # path, not just as a prefix) and as case-sensitive suffixes (the ecosystems
  504. # that emit `.min.js` / `_pb2.py` / `.pb.go` are case-consistent).
  505. SKIP_PATH_PATTERNS = (
  506. 'node_modules/', 'dist/', 'build/', '.next/', 'vendor/',
  507. '__generated__/', '__pycache__/', '.venv/', 'target/',
  508. )
  509. SKIP_FILE_SUFFIXES = (
  510. '.min.js', '.min.css', '.d.ts', '.d.mts', '.d.cts',
  511. '.lock', '_pb2.py', '.pb.go',
  512. )
  513. # Path tokens that bump a file's review priority when a commit exceeds
  514. # MAX_DIFF_FILES and we have to pick a subset. These are exactly the surfaces
  515. # single-shot and agentic reviews disagree on most (auth, routing, IPC,
  516. # subprocess, deserialization). Matched as lowercase substrings against the
  517. # path; not regex — keep it cheap.
  518. _SECURITY_RISK_PATH_TOKENS = (
  519. "auth", "login", "session", "token", "secret", "credential", "perm",
  520. "acl", "rbac", "iam", "policy",
  521. "route", "handler", "controller", "endpoint", "api/", "/api", "gateway",
  522. "middleware", "view",
  523. "exec", "subprocess", "shell", "spawn", "command",
  524. "client", "request", "fetch", "http", "url",
  525. "serialize", "pickle", "yaml", "parse", "deser",
  526. # Short tokens that would substring-match unrelated names (`format`,
  527. # `transform`, `sandbox`, `platform`) are intentionally omitted —
  528. # `sql`/`query` already cover the DB surface.
  529. "sql", "query",
  530. )
  531. # Suffixes that pass _is_reviewable_source but are almost always low-signal
  532. # in large scaffolds — generated clients, migrations, test fixtures, config
  533. # shims. These go to the BACK of the priority sort, not dropped outright.
  534. _LOW_PRIORITY_SUFFIXES = (
  535. ".gen.ts", ".gen.tsx", ".generated.ts", "_gen.py",
  536. ".test.ts", ".test.tsx", ".test.py", ".spec.ts", ".spec.js",
  537. ".config.js", ".config.ts", ".config.mjs", ".config.cjs",
  538. )
  539. _LOW_PRIORITY_PATH_TOKENS = (
  540. "/migrations/", "/alembic/versions/", "/__tests__/", "/fixtures/",
  541. )
  542. def _prioritize_diff_files(diff_files, cap):
  543. """When `diff_files` exceeds `cap`, return the top-`cap` by security
  544. relevance plus the count dropped. Otherwise return (diff_files, 0).
  545. Score = (risk_tokens_in_path, not_low_priority, added_lines). The
  546. added-lines proxy is `content.count('\\n+')` which counts diff additions
  547. cheaply without re-parsing hunks. This is a heuristic, not a guarantee —
  548. the goal is to review the likely-dangerous subset of an over-cap diff
  549. instead of reviewing nothing. Diffs that exceed the cap are typically
  550. large multi-file scaffolds, and the cross-file source→sink vulnerabilities
  551. in them concentrate in a handful of api/client/route files.
  552. """
  553. if len(diff_files) <= cap:
  554. return diff_files, 0
  555. def _score(item):
  556. fp, content = item
  557. low = fp.lower()
  558. # Prepend "/" so leading-slash patterns in _LOW_PRIORITY_PATH_TOKENS
  559. # match top-level dirs (git diff paths are repo-root-relative, e.g.
  560. # `migrations/001.py` not `/migrations/001.py`). Same trick as
  561. # _is_reviewable_source.
  562. low_slashed = "/" + low
  563. risk = sum(1 for t in _SECURITY_RISK_PATH_TOKENS if t in low)
  564. low_prio = (
  565. fp.endswith(_LOW_PRIORITY_SUFFIXES)
  566. or any(t in low_slashed for t in _LOW_PRIORITY_PATH_TOKENS)
  567. )
  568. # added_lines: count('\n+') over-counts by including '+++' header and
  569. # any literal '+' at line start in context, but it's a consistent
  570. # ordinal across files in the same diff which is all we need.
  571. added = content.count("\n+")
  572. return (risk, not low_prio, added)
  573. ranked = sorted(diff_files, key=_score, reverse=True)
  574. return ranked[:cap], len(diff_files) - cap
  575. def _is_reviewable_source(file_path):
  576. # Normalize for component matching: a path like `.next/x.js` or
  577. # `pkg/node_modules/y.ts` should both be excluded; matching against
  578. # `'/' + path` lets each pattern be checked as `'/' + p in '/' + path`
  579. # without false-positiving on `rebuild/` matching `build/`.
  580. norm = "/" + file_path.replace("\\", "/")
  581. if any(("/" + p) in norm for p in SKIP_PATH_PATTERNS):
  582. return False
  583. if file_path.endswith(SKIP_FILE_SUFFIXES):
  584. return False
  585. ext = os.path.splitext(file_path)[1].lower()
  586. if ext in SOURCE_CODE_EXTENSIONS:
  587. return True
  588. base = os.path.basename(file_path).lower()
  589. # Accept dot-suffixed variants too: `Dockerfile.dev`, `Makefile.am`,
  590. # `Jenkinsfile.release`. splitext gives ext='.dev'/'.am' for these so they
  591. # miss both the extension check and the exact-basename check otherwise.
  592. if base in SOURCE_CODE_BASENAMES \
  593. or base.split(".", 1)[0] in SOURCE_CODE_BASENAMES:
  594. return True
  595. # Extensionless files default to reviewable unless they're known
  596. # plain-text metadata or dotfiles. Covers shebang scripts under bin/ or
  597. # scripts/ (`deploy`, `run-canary`, `entrypoint`) which carry
  598. # shell-injection surface but were previously filtered out — the largest
  599. # remaining false-negative class for extensionless files. Dotfiles (`.gitignore`,
  600. # `.nvmrc`, `.env`) are config, not code; `.bashrc`-style runnables are
  601. # rare in repos and not worth the noise. The deny-list is prefix-aware on
  602. # `-`/`_` so dual-license / i18n variants (`LICENSE-MIT`, `README-CN`)
  603. # don't fall through as source.
  604. if ext == "" and not base.startswith("."):
  605. if any(base == x or base.startswith(x + "-") or base.startswith(x + "_")
  606. for x in NON_SOURCE_EXTENSIONLESS_BASENAMES):
  607. return False
  608. return True
  609. return False
  610. def extract_file_paths_from_diff(diff_output):
  611. """
  612. Extract file paths from unified diff output (without content).
  613. Only includes files with source code extensions.
  614. Returns a list of file paths.
  615. """
  616. if not diff_output or not diff_output.strip():
  617. return []
  618. paths = []
  619. file_diffs = diff_output.split("diff --git ")
  620. for file_diff in file_diffs:
  621. if not file_diff.strip():
  622. continue
  623. lines = file_diff.split('\n')
  624. header_match = re.match(r'^a/(.+?) b/(.+)$', lines[0])
  625. if not header_match:
  626. continue
  627. file_path = header_match.group(2) or header_match.group(1) or ''
  628. if not _is_reviewable_source(file_path):
  629. continue
  630. paths.append(file_path)
  631. return paths
  632. def parse_diff_into_files(diff_output):
  633. """
  634. Parse unified diff output into a list of (file_path, diff_content) tuples.
  635. Only includes files with source code extensions.
  636. """
  637. if not diff_output or not diff_output.strip():
  638. return []
  639. files = []
  640. file_diffs = diff_output.split("diff --git ")
  641. for file_diff in file_diffs:
  642. if not file_diff.strip():
  643. continue
  644. # Extract filename from first line: "a/path/to/file b/path/to/file"
  645. lines = file_diff.split('\n')
  646. header_match = re.match(r'^a/(.+?) b/(.+)$', lines[0])
  647. if not header_match:
  648. continue
  649. file_path = header_match.group(2) or header_match.group(1) or ''
  650. # Filter to source code files only
  651. if not _is_reviewable_source(file_path):
  652. continue
  653. # Extract the diff content (from first @@ onwards)
  654. diff_lines = []
  655. in_hunks = False
  656. for line in lines[1:]:
  657. if line.startswith('@@'):
  658. in_hunks = True
  659. if in_hunks:
  660. diff_lines.append(line)
  661. if diff_lines:
  662. files.append((file_path, '\n'.join(diff_lines)))
  663. return files
  664. def filter_preexisting_from_diff(diff_files, cwd, baseline_sha):
  665. """
  666. Filter out pre-existing content from diff files.
  667. When a file is fully rewritten (Write tool replaces entire content),
  668. git shows all lines as removed (-) then re-added (+). This function
  669. detects such rewrites and strips lines from the + section that also
  670. appeared in the - section, so the LLM reviewer only sees truly new code.
  671. """
  672. if not baseline_sha:
  673. return diff_files
  674. filtered = []
  675. for file_path, diff_content in diff_files:
  676. lines = diff_content.split('\n')
  677. # Collect removed and added lines (stripping the +/- prefix)
  678. removed_lines = set()
  679. added_lines = []
  680. for line in lines:
  681. if line.startswith('-') and not line.startswith('---'):
  682. removed_lines.add(line[1:].strip())
  683. elif line.startswith('+') and not line.startswith('+++'):
  684. added_lines.append(line[1:].strip())
  685. if not removed_lines:
  686. # New file, no pre-existing content to filter
  687. filtered.append((file_path, diff_content))
  688. continue
  689. # Check what fraction of added lines were pre-existing
  690. preexisting_count = sum(1 for l in added_lines if l in removed_lines)
  691. if preexisting_count == 0:
  692. filtered.append((file_path, diff_content))
  693. continue
  694. added_lines_set = set(added_lines)
  695. # Rebuild diff with pre-existing lines converted to context (space prefix).
  696. # Known imprecision: .strip() matches across indentation (so reindented
  697. # code is treated as unchanged) and the set lets one removal mask N
  698. # additions of the same stripped text. Accepted trade-off — this filter
  699. # exists for the full-file Write rewrite case where exact-match would
  700. # miss everything; the diff-review prompt's previous-findings recheck
  701. # is the backstop.
  702. new_lines = []
  703. for line in lines:
  704. if line.startswith('+') and not line.startswith('+++'):
  705. content = line[1:].strip()
  706. if content in removed_lines:
  707. # Convert to context line (pre-existing, not new)
  708. new_lines.append(' ' + line[1:])
  709. else:
  710. new_lines.append(line)
  711. elif line.startswith('-') and not line.startswith('---'):
  712. content = line[1:].strip()
  713. if content in added_lines_set:
  714. # Skip removed lines that were re-added (they become context)
  715. continue
  716. else:
  717. new_lines.append(line)
  718. else:
  719. new_lines.append(line)
  720. filtered.append((file_path, '\n'.join(new_lines)))
  721. return filtered