gitutil.py 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240
  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. # A work-tree `git diff` that meets stat-stale entries (mtime changed,
  36. # content same) rewrites the index under index.lock, GIT_OPTIONAL_LOCKS or
  37. # not. Off, every diff here only reads the index, so none needs a copy.
  38. "-c", "diff.autoRefreshIndex=false",
  39. # The diff parsers read `diff --git a/<old> b/<new>` headers. A user's
  40. # diff.mnemonicPrefix (c/ w/ i/), diff.noprefix or diff.srcPrefix /
  41. # diff.dstPrefix changes that shape, and every file would be dropped.
  42. "-c", "diff.mnemonicPrefix=false",
  43. "-c", "diff.noprefix=false",
  44. "-c", "diff.srcPrefix=a/",
  45. "-c", "diff.dstPrefix=b/",
  46. ]
  47. SAFE_GIT_CONFIG = (
  48. ("core.fsmonitor", "false"),
  49. ("core.hooksPath", "/dev/null"),
  50. )
  51. # The hook's git runs in the background beside the session's own git. Without
  52. # this, `git status` holds .git/index.lock while it writes back a refreshed
  53. # index: a concurrent `git add` fails, and a timeout kill leaves the lock.
  54. SAFE_GIT_ENV = {
  55. "GIT_OPTIONAL_LOCKS": "0",
  56. }
  57. def git_config_env(pairs, base=None):
  58. base = os.environ if base is None else base
  59. try:
  60. n = max(0, int(base.get("GIT_CONFIG_COUNT") or 0))
  61. except (TypeError, ValueError):
  62. n = 0
  63. env = {}
  64. for i, (k, v) in enumerate(pairs, start=n):
  65. env[f"GIT_CONFIG_KEY_{i}"] = k
  66. env[f"GIT_CONFIG_VALUE_{i}"] = v
  67. env["GIT_CONFIG_COUNT"] = str(n + len(pairs))
  68. return env
  69. def apply_safe_git_env():
  70. os.environ.update(git_config_env(SAFE_GIT_CONFIG))
  71. os.environ.update(SAFE_GIT_ENV)
  72. def _git_rev_parse_head(cwd):
  73. """Return the current HEAD SHA, or None if not a git repo / no commits."""
  74. try:
  75. # See #2099: text=True on Windows cp1252 crashes the reader thread on
  76. # any UTF-8 byte undefined in cp1252 (e.g. via a git error message
  77. # referencing a non-ASCII filename in stderr). stdout is a SHA so it
  78. # IS safe; stderr is not. capture_output=True with bytes-by-default
  79. # never decodes, so the reader thread can't crash.
  80. result = subprocess.run(
  81. [*GIT_CMD, "rev-parse", "HEAD"],
  82. cwd=cwd, capture_output=True, timeout=5
  83. )
  84. if result.returncode == 0 and result.stdout.strip():
  85. return result.stdout.decode("utf-8", errors="replace").strip()
  86. return None
  87. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  88. return None
  89. def _find_git_index(cwd):
  90. """
  91. Find the real index file for a git repo. Handles worktrees where .git
  92. is a file pointing to the main repo's gitdir.
  93. Returns the absolute path to the index file, or None.
  94. """
  95. try:
  96. # See #2099: stdout here is a PATH which can contain non-ASCII bytes
  97. # (e.g. C:\אבטחה\repo\.git). text=True decodes via cp1252 strict on
  98. # Windows → crashes the reader thread → returns stdout=None →
  99. # caller does .strip() on None → AttributeError. Decode manually.
  100. result = subprocess.run(
  101. [*GIT_CMD, "rev-parse", "--git-dir"],
  102. cwd=cwd, capture_output=True, timeout=5
  103. )
  104. if result.returncode != 0:
  105. return None
  106. git_dir = result.stdout.decode("utf-8", errors="replace").strip()
  107. if not os.path.isabs(git_dir):
  108. git_dir = os.path.join(cwd, git_dir)
  109. index_path = os.path.join(git_dir, "index")
  110. return index_path if os.path.isfile(index_path) else None
  111. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  112. return None
  113. def _diff_pathspec(cwd, paths):
  114. """Convert absolute touched-paths to repo-relative pathspec args for
  115. git diff. Paths outside cwd (e.g. ~/.claude/…) are dropped. Returns the
  116. list to splice after `--`, or [] for an unrestricted diff. realpath both
  117. sides so the macOS /var ↔ /private/var symlink doesn't make in-repo
  118. paths look external."""
  119. if not paths:
  120. return []
  121. cwd_abs = os.path.realpath(cwd)
  122. rel = []
  123. for p in paths:
  124. try:
  125. r = os.path.relpath(os.path.realpath(p), cwd_abs)
  126. except ValueError:
  127. continue
  128. if r.startswith(".."):
  129. continue
  130. rel.append(r)
  131. return ["--"] + rel if rel else []
  132. _TEMP_INDEX_PREFIX = "security_hook_idx_"
  133. # A copy is named <prefix>pid<hook pid>_<8 random chars>, so a later run can
  134. # tell that the hook which made it is gone. Names from earlier releases have
  135. # only the 8 random characters and never match.
  136. _OWNER_PID = re.compile(re.escape(_TEMP_INDEX_PREFIX) + r"pid(\d+)_[^.]{8}(?:\.|$)")
  137. # A live copy is in use for under a minute (a couple of bounded git calls), so
  138. # anything this old belongs to a run that was killed.
  139. _TEMP_INDEX_STALE_S = 1800
  140. # Unlinking index-sized files is slow; a large backlog drains over several
  141. # calls instead of stalling one hook past its timeout.
  142. _SWEEP_BUDGET_S = 2
  143. def _owner_is_gone(name):
  144. """True when `name` records the pid of the hook that made it and no such
  145. process exists. A reused pid reads as alive: the file then waits for the
  146. age limit. Never probes on Windows, where os.kill would end the process."""
  147. if not hasattr(os, "getuid"):
  148. return False
  149. m = _OWNER_PID.match(name)
  150. if not m:
  151. return False
  152. try:
  153. os.kill(int(m.group(1)), 0)
  154. except ProcessLookupError:
  155. return True
  156. except (OSError, OverflowError, ValueError):
  157. return False
  158. return False
  159. def _sweep_stale_indexes(dirpath, uid):
  160. """Delete index copies in `dirpath` that a killed run left behind. Only
  161. regular files owned by `uid` (None: no ownership check) are touched.
  162. A copy goes as soon as the hook that made it is gone: a killed hook can
  163. leave one per run, and index-sized files fill a disk long before the age
  164. limit. The age limit covers names without a pid and reused pids.
  165. Age is the newer of mtime and ctime: a copy keeps .git/index's mtime,
  166. which may be days old, so mtime alone would delete a file that another
  167. running hook is still using."""
  168. import stat
  169. import time
  170. cutoff = time.time() - _TEMP_INDEX_STALE_S
  171. deadline = time.monotonic() + _SWEEP_BUDGET_S
  172. try:
  173. names = os.listdir(dirpath)
  174. except OSError:
  175. return
  176. for name in names:
  177. if not name.startswith(_TEMP_INDEX_PREFIX):
  178. continue
  179. p = os.path.join(dirpath, name)
  180. try:
  181. st = os.lstat(p)
  182. if not stat.S_ISREG(st.st_mode):
  183. continue
  184. if uid is not None and st.st_uid != uid:
  185. continue
  186. if max(st.st_mtime, st.st_ctime) < cutoff or _owner_is_gone(name):
  187. os.unlink(p)
  188. if time.monotonic() > deadline:
  189. return
  190. except OSError:
  191. pass
  192. def _sweep_stale_stash_indexes(real_index):
  193. """Delete `<index>.stash.<pid>` files a killed `git stash create` left
  194. next to the real index. Releases before 2.0.10 ran it there, so a clone
  195. can hold hundreds, each the size of the index, and nothing else removes
  196. them. Only our own regular files past the age limit go: git keeps one for
  197. seconds. `index.lock` is never touched; a live git may hold it."""
  198. import stat
  199. import time
  200. uid = os.getuid() if hasattr(os, "getuid") else None
  201. cutoff = time.time() - _TEMP_INDEX_STALE_S
  202. deadline = time.monotonic() + _SWEEP_BUDGET_S
  203. dirpath = os.path.dirname(real_index)
  204. stale = re.compile(re.escape(os.path.basename(real_index)) + r"\.stash\.\d+$")
  205. try:
  206. names = os.listdir(dirpath)
  207. except OSError:
  208. return
  209. for name in names:
  210. if not stale.match(name):
  211. continue
  212. p = os.path.join(dirpath, name)
  213. try:
  214. st = os.lstat(p)
  215. if not stat.S_ISREG(st.st_mode):
  216. continue
  217. if uid is not None and st.st_uid != uid:
  218. continue
  219. if max(st.st_mtime, st.st_ctime) < cutoff:
  220. os.unlink(p)
  221. if time.monotonic() > deadline:
  222. return
  223. except OSError:
  224. pass
  225. # Index copies this process has made and not yet removed.
  226. _live_copies = set()
  227. # A hook working on a copy needs up to three times the index's size for a
  228. # moment (the copy, and git's rewrite and stash index next to it). With many
  229. # agents in one clone that alone can fill a disk, so only so many hooks per
  230. # user work on a copy at once; the others wait, then go without.
  231. _MAX_LIVE_COPIES = 8
  232. _SLOT_WAIT_S = 5
  233. def _copy_slots(tmpdir, index_bytes):
  234. """How many hooks may work on a copy at once: what fits into a quarter of
  235. the free space at three times the index each, from 1 to
  236. _MAX_LIVE_COPIES."""
  237. import shutil
  238. try:
  239. free = shutil.disk_usage(tmpdir).free
  240. except OSError:
  241. return 1
  242. return max(1, min(_MAX_LIVE_COPIES, free // (12 * max(index_bytes, 1))))
  243. @contextlib.contextmanager
  244. def _copy_slot(tmpdir, index_bytes=0):
  245. """Hold one of the copy slots; yields False when none came free in
  246. _SLOT_WAIT_S. A slot is an flock, so the kernel frees it when its holder
  247. dies, however it dies. No cap without flock or a private dir."""
  248. import time
  249. try:
  250. import fcntl
  251. except ImportError:
  252. fcntl = None
  253. if fcntl is None or tmpdir is None:
  254. yield True
  255. return
  256. deadline = time.monotonic() + _SLOT_WAIT_S
  257. held = None
  258. while held is None:
  259. for i in range(_copy_slots(tmpdir, index_bytes)):
  260. try:
  261. fd = os.open(os.path.join(tmpdir, f"slot{i}.lock"),
  262. os.O_RDWR | os.O_CREAT, 0o600)
  263. except OSError:
  264. continue
  265. try:
  266. fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
  267. held = fd
  268. break
  269. except OSError:
  270. os.close(fd)
  271. if held is None:
  272. if time.monotonic() >= deadline:
  273. break
  274. time.sleep(0.1)
  275. try:
  276. yield held is not None
  277. finally:
  278. if held is not None:
  279. os.close(held)
  280. def _remove_copy(tmp_index):
  281. """Remove an index copy and the files git wrote next to it."""
  282. import glob
  283. for p in glob.glob(glob.escape(tmp_index) + "*"):
  284. try:
  285. os.unlink(p)
  286. except OSError:
  287. pass
  288. def remove_copies_on_termination():
  289. """Remove this process's index copies when it is told to terminate.
  290. Python's default SIGTERM action ends the process without running
  291. `finally`, which left an index-sized file behind each time."""
  292. import signal
  293. def _on_term(signum, _frame):
  294. for tmp_index in list(_live_copies):
  295. _remove_copy(tmp_index)
  296. os._exit(128 + signum)
  297. try:
  298. signal.signal(signal.SIGTERM, _on_term)
  299. except (ValueError, OSError, AttributeError):
  300. pass # not the main thread, or no SIGTERM here
  301. def _hook_tmpdir():
  302. """Private per-user directory for throwaway index copies, or None to use
  303. the bare temp dir.
  304. An index copy can be hundreds of MB and a killed hook cannot clean up, so
  305. copies live in one 0700 directory and anything stale is swept on entry,
  306. here and in the bare temp dir. The name is predictable and the temp dir
  307. may be shared, so anything that is not a real directory we own is
  308. refused. Windows has no uid and its temp dir is already per-user."""
  309. import stat
  310. import tempfile
  311. base = tempfile.gettempdir()
  312. uid = os.getuid() if hasattr(os, "getuid") else None
  313. _sweep_stale_indexes(base, uid)
  314. if uid is None:
  315. return None
  316. d = os.path.join(base, f"claude-security-guidance-{uid}")
  317. try:
  318. os.mkdir(d, 0o700)
  319. except FileExistsError:
  320. pass
  321. except OSError:
  322. return None
  323. try:
  324. st = os.lstat(d)
  325. except OSError:
  326. return None
  327. if not stat.S_ISDIR(st.st_mode) or st.st_uid != uid:
  328. return None
  329. try:
  330. os.chmod(d, 0o700)
  331. except OSError:
  332. pass
  333. _sweep_stale_indexes(d, uid)
  334. return d
  335. @contextlib.contextmanager
  336. def _temp_index(cwd, untracked_paths=None):
  337. """Yield an env dict pointing GIT_INDEX_FILE at a throwaway copy of the
  338. repo's index with `git add --intent-to-add` applied, so untracked files
  339. show up in subsequent `git diff` calls without touching the user's real
  340. index. Yields None if no index can be found (bare repo / not a repo) or
  341. no copy slot came free; the caller should fall back to a plain diff.
  342. Always cleans up the temp file.
  343. Perf: when `untracked_paths` is given, only those paths are added (O(n)
  344. in untracked count). The default `add -N .` stats every file in the
  345. worktree — slow in large repos vs fast targeted scan. v2 callers
  346. already know the untracked set from `git status --porcelain`, so they
  347. pass it; v1 keeps the whole-tree scan since it has no prior list.
  348. Cleanup removes `<tmp_index>*`, not just the copy: git writes
  349. `<index>.lock` and `<index>.stash.<pid>` next to the index it is given and
  350. cannot remove them itself when a subprocess timeout kills it."""
  351. real_index = _find_git_index(cwd)
  352. if not real_index:
  353. yield None
  354. return
  355. _sweep_stale_stash_indexes(real_index)
  356. tmpdir = _hook_tmpdir()
  357. try:
  358. index_bytes = os.path.getsize(real_index)
  359. except OSError:
  360. index_bytes = 0
  361. with _copy_slot(tmpdir, index_bytes) as got_slot:
  362. if not got_slot:
  363. debug_log("no index copy slot came free; going without a copy")
  364. yield None
  365. return
  366. with _copied_index(cwd, real_index, tmpdir, untracked_paths) as env:
  367. yield env
  368. @contextlib.contextmanager
  369. def _copied_index(cwd, real_index, tmpdir, untracked_paths):
  370. import shutil
  371. import tempfile
  372. tmp_fd, tmp_index = tempfile.mkstemp(
  373. prefix=f"{_TEMP_INDEX_PREFIX}pid{os.getpid()}_", dir=tmpdir)
  374. os.close(tmp_fd)
  375. _live_copies.add(tmp_index)
  376. try:
  377. # A hard link where the filesystem allows it: no bytes are copied, and
  378. # git never writes an index in place (it renames a new file over it),
  379. # so the real index cannot change through the link. Either way the
  380. # copy must keep .git/index's mtime (copy2, not copyfile): git
  381. # re-reads any file whose mtime is not older than the index file's,
  382. # and with a fresh mtime it would miss a same-size edit made in the
  383. # same second as the last index write.
  384. try:
  385. os.unlink(tmp_index)
  386. os.link(real_index, tmp_index)
  387. except (OSError, NotImplementedError, AttributeError):
  388. shutil.copy2(real_index, tmp_index)
  389. env = {**os.environ, "GIT_INDEX_FILE": tmp_index}
  390. if untracked_paths is None:
  391. add_args = ["."]
  392. elif untracked_paths:
  393. # `git add -N -- a b nonexistent` is atomic — one missing path
  394. # makes it exit 128 and add NOTHING, so a file removed between
  395. # `git status` and here would silently drop ALL untracked files
  396. # from the diff. --ignore-missing only works with --dry-run, so
  397. # filter to surviving paths (lexists so dangling symlinks count).
  398. surviving = [p for p in untracked_paths
  399. if os.path.lexists(os.path.join(cwd, p))]
  400. add_args = ["--"] + surviving if surviving else None
  401. else:
  402. add_args = None
  403. if add_args:
  404. # No stdout used here (only returncode matters), but text=True
  405. # still spawns reader threads that decode stderr — git error
  406. # messages can reference non-ASCII filenames and crash on
  407. # cp1252. See #2099. Drop text=True so bytes stay raw.
  408. subprocess.run(
  409. [*GIT_CMD, "add", "--intent-to-add"] + add_args,
  410. cwd=cwd, capture_output=True, timeout=10,
  411. env=env,
  412. )
  413. yield env
  414. finally:
  415. _remove_copy(tmp_index)
  416. _live_copies.discard(tmp_index)
  417. def _git_toplevel(cwd):
  418. """Absolute repo root for `cwd`, or None if not in a work tree."""
  419. try:
  420. # See #2099: stdout is a PATH — `C:\אבטחה\repo` returned as UTF-8
  421. # bytes by git. text=True would decode via cp1252 strict on Windows
  422. # → reader-thread crash. Decode manually with errors="replace".
  423. r = subprocess.run(
  424. [*GIT_CMD, "rev-parse", "--show-toplevel"],
  425. cwd=cwd, capture_output=True, timeout=5,
  426. )
  427. if r.returncode != 0:
  428. return None
  429. path = r.stdout.decode("utf-8", errors="replace").strip()
  430. return path if path else None
  431. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  432. return None
  433. def _git_dir(repo_root):
  434. """Absolute shared `.git` directory for repo_root.
  435. Uses `rev-parse --git-common-dir` so linked worktrees resolve to the
  436. SHARED gitdir, not the per-worktree `.git/worktrees/<name>/`. That way
  437. push-sweep's reviewed-shas record (and the bash-hook-once sentinel)
  438. is per-clone — a commit reviewed in one worktree counts as reviewed
  439. if a different worktree later pushes it. Returns None on failure so
  440. callers can degrade (push-sweep state is best-effort).
  441. """
  442. try:
  443. # See #2099: stdout is a PATH (shared gitdir), may be non-ASCII.
  444. # Decode bytes manually to avoid cp1252 reader-thread crash.
  445. r = subprocess.run(
  446. [*GIT_CMD, "rev-parse", "--git-common-dir"],
  447. cwd=repo_root, capture_output=True, timeout=5,
  448. )
  449. if r.returncode != 0:
  450. return None
  451. d = r.stdout.decode("utf-8", errors="replace").strip()
  452. return d if os.path.isabs(d) else os.path.join(repo_root, d)
  453. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  454. return None
  455. def _git_rev_list_range(repo_root, base, head="HEAD"):
  456. """Shas in `base..head`, oldest→newest. Empty list on error."""
  457. try:
  458. # See #2099: stdout is ASCII SHAs, but stderr can carry git error
  459. # messages referencing non-ASCII filenames — keep bytes raw.
  460. r = subprocess.run(
  461. [*GIT_CMD, "rev-list", "--reverse", f"{base}..{head}"],
  462. cwd=repo_root, capture_output=True, timeout=10,
  463. )
  464. if r.returncode != 0:
  465. return []
  466. return [s for s in r.stdout.decode("utf-8", errors="replace").strip().split("\n") if s]
  467. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  468. return []
  469. def _git_diff_range(repo_root, base, head="HEAD"):
  470. """`git diff -p base head` as text on success, None on error.
  471. Distinguishing failure from success-with-empty-diff matters: the push-sweep
  472. caller marks the tail reviewed when the diff is empty (nothing to review),
  473. but on failure (timeout, non-zero exit, missing git) it must NOT mark
  474. them reviewed — otherwise unreviewed commits get permanently silenced.
  475. """
  476. try:
  477. # GIT_CMD globally passes core.quotePath=false (see definition) so
  478. # non-ASCII paths in `diff --git a/... b/...` headers come through as
  479. # raw UTF-8, not C-quoted. Required by the downstream
  480. # parse_diff_into_files / extract_file_paths_from_diff regex.
  481. r = subprocess.run(
  482. [*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head],
  483. cwd=repo_root, capture_output=True, timeout=30,
  484. )
  485. if r.returncode != 0:
  486. return None
  487. return r.stdout.decode("utf-8", errors="replace")
  488. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  489. return None
  490. def _detect_main_branch(repo_root):
  491. for ref in ("origin/HEAD", "origin/main", "origin/master", "main", "master"):
  492. try:
  493. # See #2099: stdout is a SHA but stderr can carry non-ASCII git
  494. # warnings — keep bytes raw to avoid cp1252 reader-thread crash.
  495. r = subprocess.run(
  496. [*GIT_CMD, "rev-parse", "--verify", "-q", ref],
  497. cwd=repo_root, capture_output=True, timeout=5,
  498. )
  499. if r.returncode == 0 and r.stdout.strip():
  500. return ref
  501. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  502. pass
  503. return None
  504. def _git_reflog_recent_commits(repo_root, max_age_s=120, max_n=5):
  505. """Return (fresh_commit_shas, stale_count) from the HEAD reflog.
  506. Scans the last `max_n` reflog entries and returns the SHAs whose action is
  507. `commit*` AND whose commit timestamp is within `max_age_s` of now,
  508. newest-first. `stale_count` is the number of commit-action entries that
  509. were too old (so the caller can distinguish "no commit happened" from
  510. "commit happened earlier than the window").
  511. Used by commit-review when stdout-based `[branch sha]` detection fails
  512. (output piped/redirected/-q, or a chained command after `git commit`
  513. pushed the success line off — `git commit && git push` makes HEAD@{0}
  514. `update by push`, not `commit:`). The HEAD@{0}-only check
  515. keeps the not-yet-visible-HEAD skip rare; analysis showed the
  516. residual is dominated by these chained-command and noop-guard cases.
  517. Safety vs. blindly reading HEAD:
  518. - cross-repo (`cd ../other && git commit`): repo_root's own reflog has
  519. no fresh commit, so this returns ([], 0).
  520. - commit actually failed (pre-commit reject, nothing-staged): reflog's
  521. recent entries are the prior checkout/commit/reset → ([], 0) or only
  522. stale entries.
  523. - HEAD raced ahead (a second commit landed before this async hook ran):
  524. both commits appear in the scan and both get reviewed — correct.
  525. - prior Bash call's commit within the window: would be returned here,
  526. but the call site deduplicates against `.git/sg-reviewed-shas` so a
  527. SHA is reviewed at most once. This is also the non-overlap invariant
  528. with push-sweep.
  529. """
  530. if not repo_root:
  531. return [], 0
  532. try:
  533. # %gs (the reflog subject) is `commit: <commit-msg first line>` and can
  534. # contain `|`; put it LAST so split("|", 2) leaves it intact. %H is
  535. # hex and %ct is integer, so the first two fields are delimiter-safe.
  536. #
  537. # Bytes + decode utf-8/replace: %gs embeds commit-message subjects
  538. # which git stores as raw bytes — commits can be authored in
  539. # latin-1 / cp1252 / shift-jis etc., and text=True would raise
  540. # UnicodeDecodeError in the subprocess reader thread on Windows
  541. # cp1252 (subprocess.run returns r.stdout=None, then
  542. # r.stdout.splitlines() AttributeErrors). Mirrors the existing
  543. # migration at security_reminder_hook.py:540 — same pattern was
  544. # missed here. See anthropics/claude-plugins-official#2056.
  545. r = subprocess.run(
  546. [*GIT_CMD, "log", "-g", "-n", str(max_n),
  547. "--format=%H|%ct|%gs", "HEAD"],
  548. cwd=repo_root, capture_output=True, timeout=5,
  549. )
  550. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
  551. return [], 0
  552. if r.returncode != 0:
  553. return [], 0
  554. stdout = (r.stdout or b"").decode("utf-8", errors="replace")
  555. import time as _time
  556. now = int(_time.time())
  557. fresh, stale = [], 0
  558. for idx, line in enumerate(stdout.splitlines()):
  559. parts = line.split("|", 2)
  560. if len(parts) != 3:
  561. continue
  562. sha, ct, subject = parts
  563. # `commit: msg`, `commit (amend): msg`, `commit (initial): msg`,
  564. # `commit (merge): msg` — all create a reviewable commit object.
  565. if not subject.startswith("commit"):
  566. continue
  567. try:
  568. age = now - int(ct)
  569. except ValueError:
  570. continue
  571. # HEAD@{0} (idx==0) is exempt from the age gate. The gate exists to
  572. # bound the WIDENED HEAD@{1..max_n-1} scan from picking up commits
  573. # made by *prior* Bash calls; HEAD@{0} is by definition the most
  574. # recent reflog entry and was previously accepted unconditionally
  575. # (_git_reflog_head_if_just_committed previously had no age check).
  576. # Applying max_age_s to idx==0 made the not-yet-visible-HEAD skip
  577. # noticeably more frequent on chained
  578. # `git commit && <slow command>` where %ct is >120s old by the
  579. # time the async PostToolUse hook fires.
  580. if idx == 0 or age <= max_age_s:
  581. fresh.append(sha)
  582. else:
  583. stale += 1
  584. return fresh, stale
  585. def _git_name_only(cwd, base, include_untracked=False):
  586. """Return the set of repo-root-relative paths that differ from `base`,
  587. or None if git failed (unresolvable ref, not a repo, timeout). Callers
  588. must distinguish None (error → don't trust as a filter) from set()
  589. (genuinely nothing changed). `-c core.quotePath=false -z` keeps non-ASCII
  590. and space-containing paths intact.
  591. Against the work tree the set can also hold a file whose stat changed and
  592. whose content did not: the diff does not refresh the index. Callers
  593. intersect with `git status`, which compares content."""
  594. # Decode stdout/stderr as UTF-8 with errors="replace" instead of using
  595. # text=True. core.quotePath=false makes git emit raw UTF-8 for non-ASCII
  596. # paths, and text=True on Windows decodes via cp1252 strict — a non-ASCII
  597. # changed path would crash the subprocess reader thread, leave
  598. # result.stdout=None, and propagate AttributeError out of the helper.
  599. # Same fix shape as diffstate._list_untracked. See #2056.
  600. def _run(env):
  601. # core.quotePath=false comes from GIT_CMD globally (see definition).
  602. result = subprocess.run(
  603. [*GIT_CMD, "diff", "--name-only", "-z", base],
  604. cwd=cwd, capture_output=True, timeout=30,
  605. env=env,
  606. )
  607. if result.returncode != 0:
  608. stderr_str = (result.stderr or b"").decode("utf-8", errors="replace")
  609. debug_log(f"_git_name_only({base!r}) rc={result.returncode}: {stderr_str[:200]}")
  610. return None
  611. stdout = (result.stdout or b"").decode("utf-8", errors="replace")
  612. return {p for p in stdout.split("\0") if p}
  613. try:
  614. # The diff only reads the index (diff.autoRefreshIndex is off in
  615. # GIT_CMD); a copy is for `add -N` alone.
  616. if not include_untracked:
  617. return _run(None)
  618. with _temp_index(cwd) as env:
  619. return _run(env)
  620. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  621. debug_log(f"_git_name_only({base!r}) error: {e}")
  622. return None
  623. def _git_status_porcelain(cwd):
  624. """One `git status --porcelain=v1 -z` → (tracked_dirty, untracked) sets of
  625. repo-root-relative paths, or (None, None) on error. Replaces the
  626. `_temp_index + git diff HEAD --name-only` pair for the v2 dirty_now
  627. computation: faster in large repos, and yields the
  628. untracked set separately so the later get_git_diff can do a targeted
  629. `add -N -- <files>` instead of a whole-tree `add -N .`.
  630. -uall: list individual files inside untracked directories (default
  631. collapses to `dir/`). Required so the untracked set subtracts cleanly
  632. against the UPS-time `_list_untracked` snapshot, which uses ls-files and
  633. therefore always lists individual files."""
  634. # Lenient decode: same UTF-8 + errors="replace" pattern as the
  635. # sibling helpers — a non-ASCII path in the worktree would otherwise
  636. # crash the cp1252 reader thread on Windows. See #2056.
  637. try:
  638. # core.quotePath=false comes from GIT_CMD globally (see definition).
  639. r = subprocess.run(
  640. [*GIT_CMD, "status", "--porcelain=v1", "-uall", "-z"],
  641. cwd=cwd, capture_output=True, timeout=30,
  642. )
  643. if r.returncode != 0:
  644. stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
  645. debug_log(f"_git_status_porcelain rc={r.returncode}: {stderr_str[:200]}")
  646. return None, None
  647. tracked, untracked = set(), set()
  648. stdout = (r.stdout or b"").decode("utf-8", errors="replace")
  649. entries = stdout.split("\0")
  650. i = 0
  651. while i < len(entries):
  652. e = entries[i]
  653. if not e:
  654. i += 1
  655. continue
  656. xy, path = e[:2], e[3:]
  657. if xy == "??":
  658. untracked.add(path)
  659. else:
  660. tracked.add(path)
  661. # Rename/copy entries are XY old\0new\0 — second NUL field is
  662. # the origin path; consume it so it isn't misparsed as a new
  663. # 2-char-status entry.
  664. if "R" in xy or "C" in xy:
  665. i += 1
  666. i += 1
  667. return tracked, untracked
  668. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  669. # ValueError guards against any future strict-decode regression
  670. # so the helper degrades to (None, None) instead of crashing.
  671. debug_log(f"_git_status_porcelain error: {e}")
  672. return None, None
  673. def _is_ancestor(cwd, maybe_ancestor, descendant):
  674. """True if `maybe_ancestor` is reachable from `descendant` (i.e. HEAD
  675. moved forward via commit/merge, not sideways via checkout)."""
  676. try:
  677. # See #2099: only returncode matters, but text=True spawns reader
  678. # threads that decode stderr — git error messages can carry non-ASCII
  679. # filenames. Drop text=True to keep bytes raw, avoid cp1252 crash.
  680. result = subprocess.run(
  681. [*GIT_CMD, "merge-base", "--is-ancestor", maybe_ancestor, descendant],
  682. cwd=cwd, capture_output=True, timeout=5,
  683. )
  684. return result.returncode == 0
  685. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  686. return False
  687. def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_paths=None):
  688. """
  689. Get the git diff between the baseline SHA and the current working tree,
  690. including untracked (new) files.
  691. Uses a temporary copy of the git index (GIT_INDEX_FILE) so the user's
  692. real index is never modified. The temp index gets intent-to-add entries
  693. for untracked files, making them visible in the diff output. Cleanup
  694. is just deleting the temp file in a finally block.
  695. If `paths` is given, the diff is restricted to those paths (relative to
  696. cwd; absolute paths are converted, paths outside cwd are dropped).
  697. `untracked_paths` (repo-root-relative) is forwarded to _temp_index so it
  698. can add only those files instead of scanning the whole worktree.
  699. """
  700. pathspec = _diff_pathspec(cwd, paths)
  701. if paths and not pathspec:
  702. # Caller restricted to specific paths but none are inside this repo
  703. # (e.g. only ~/.claude/... edits). Returning "" flows to skip(6); an
  704. # empty pathspec would mean an UNRESTRICTED diff — the bug this whole
  705. # change exists to fix.
  706. return ""
  707. # core.quotePath=false comes from GIT_CMD globally (see definition).
  708. cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
  709. try:
  710. if untracked_paths is not None and not untracked_paths:
  711. # Nothing to `add -N`, and the diff only reads the index.
  712. result = subprocess.run(cmd, cwd=cwd, capture_output=True, timeout=30)
  713. else:
  714. with _temp_index(cwd, untracked_paths) as env:
  715. # env is None when there is no index (bare repo / not a repo)
  716. # or no copy slot — diff still runs, just without
  717. # untracked-file support.
  718. result = subprocess.run(cmd, cwd=cwd, capture_output=True, timeout=30, env=env)
  719. if result.returncode != 0:
  720. debug_log(f"git diff failed: {result.stderr[:200].decode('utf-8', errors='replace')}")
  721. return None
  722. # Decode with errors='replace' so binary diffs don't crash
  723. return result.stdout.decode("utf-8", errors="replace")
  724. except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
  725. debug_log(f"git diff error: {e}")
  726. return None
  727. # Source file extensions worth reviewing for security
  728. SOURCE_CODE_EXTENSIONS = {
  729. '.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.java', '.rb', '.php',
  730. '.rs', '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala',
  731. '.html', '.htm', '.ejs', '.yaml', '.yml', '.properties',
  732. '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
  733. '.sh', '.bash', '.zsh', '.fish', '.ksh', '.ps1', '.sql',
  734. '.gradle', '.groovy',
  735. '.tf', '.hcl', '.tfvars',
  736. '.json', '.toml', '.ipynb',
  737. }
  738. # Reviewable files identified by basename rather than extension (lowercased).
  739. # These are by-convention extensionless but contain executable recipes/DSL
  740. # with shell/exec surface (Make recipes, Jenkinsfile Groovy, Rakefile Ruby).
  741. SOURCE_CODE_BASENAMES = {
  742. 'dockerfile', 'makefile', 'gnumakefile', 'jenkinsfile', 'vagrantfile',
  743. 'rakefile', 'gemfile', 'procfile', 'brewfile', 'justfile',
  744. }
  745. # Extensionless basenames that are NOT source — plain-text metadata. Anything
  746. # extensionless not in this set is treated as source (likely a shebang script
  747. # under bin/ or scripts/). Analysis of skipped reviews found
  748. # extensionless executables (bin/deploy, scripts/run-canary) were the largest
  749. # remaining false-negative class — they carry shell-injection surface but
  750. # `splitext` gives '' so they were filtered out. _cap_files_for_prompt bounds
  751. # the byte cost downstream, and the reviewer ignores prose, so opting
  752. # extensionless IN with this small deny-list is the better default than
  753. # opting OUT.
  754. NON_SOURCE_EXTENSIONLESS_BASENAMES = {
  755. 'license', 'licence', 'copying', 'notice', 'patents', 'authors',
  756. 'contributors', 'maintainers', 'changelog', 'changes', 'news',
  757. 'readme', 'todo', 'install', 'version', 'codeowners',
  758. 'owners', 'copyright',
  759. }
  760. # Directory components and file suffixes that are never worth reviewing even
  761. # when the extension is in SOURCE_CODE_EXTENSIONS — vendored deps, build
  762. # output, generated code, minified bundles, lockfiles, protobuf stubs.
  763. # Matched as path *components* (so `node_modules/` matches anywhere in the
  764. # path, not just as a prefix) and as case-sensitive suffixes (the ecosystems
  765. # that emit `.min.js` / `_pb2.py` / `.pb.go` are case-consistent).
  766. SKIP_PATH_PATTERNS = (
  767. 'node_modules/', 'dist/', 'build/', '.next/', 'vendor/',
  768. '__generated__/', '__pycache__/', '.venv/', 'target/',
  769. )
  770. SKIP_FILE_SUFFIXES = (
  771. '.min.js', '.min.css', '.d.ts', '.d.mts', '.d.cts',
  772. '.lock', '_pb2.py', '.pb.go',
  773. )
  774. # Files that hold secrets by naming convention are never sent for review. The
  775. # review covers everything dirty in the work tree, including files the user
  776. # changed and Claude never opened, so a key file or an encrypted dotfile source
  777. # would otherwise leave the machine in the review request. The list is kept to
  778. # names that are secret stores by convention; ordinary source such as
  779. # `private_api.py` or `id_generator.py` stays reviewable.
  780. _SSH_PRIVATE_KEY_BASENAMES = frozenset((
  781. 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ecdsa_sk', 'id_ed25519',
  782. 'id_ed25519_sk', 'id_xmss',
  783. ))
  784. _PRIVATE_KEY_NAME_SUFFIXES = ('_rsa', '_dsa', '_ecdsa', '_ed25519')
  785. _CREDENTIAL_BASENAMES = frozenset((
  786. 'credentials', '.netrc', '.pgpass', '.npmrc', '.pypirc',
  787. '.git-credentials', '.dockercfg',
  788. ))
  789. # chezmoi encodes a target file's attributes as stacked name prefixes
  790. # (`private_dot_netrc`, `encrypted_private_token`); `dot_` stands for a
  791. # leading dot.
  792. _CHEZMOI_ATTR_PREFIXES = (
  793. 'after_', 'before_', 'create_', 'empty_', 'encrypted_', 'exact_',
  794. 'executable_', 'external_', 'literal_', 'modify_', 'once_', 'onchange_',
  795. 'private_', 'readonly_', 'remove_', 'run_', 'symlink_',
  796. )
  797. # An `encrypted_` source keeps its target's name, so `encrypted_store.py` could
  798. # be either ciphertext or code about encryption. Treat it as ciphertext only
  799. # when it has no extension or a data-file one.
  800. _ENCRYPTED_DATA_EXTENSIONS = frozenset((
  801. '', '.json', '.yaml', '.yml', '.toml', '.properties', '.tfvars',
  802. ))
  803. # Paths skipped as secret-shaped during this hook run. The Stop hook reports
  804. # the count (never the names) so the skip can be watched in telemetry.
  805. _secret_shaped_skipped = set()
  806. def _skip_secret_shaped(file_path):
  807. if not _is_secret_shaped(file_path):
  808. return False
  809. _secret_shaped_skipped.add(file_path)
  810. return True
  811. def _is_secret_shaped(file_path):
  812. base = os.path.basename(file_path.replace("\\", "/")).lower()
  813. target = base
  814. encrypted = False
  815. stripped = True
  816. while stripped:
  817. stripped = False
  818. for prefix in _CHEZMOI_ATTR_PREFIXES:
  819. if target.startswith(prefix) and len(target) > len(prefix):
  820. target = target[len(prefix):]
  821. encrypted = encrypted or prefix == 'encrypted_'
  822. stripped = True
  823. break
  824. if target.startswith('dot_'):
  825. target = '.' + target[4:]
  826. ext = os.path.splitext(target)[1]
  827. if encrypted and ext in _ENCRYPTED_DATA_EXTENSIONS:
  828. return True
  829. for name in {base, target}:
  830. if name.startswith('.env'):
  831. return True
  832. if name in _SSH_PRIVATE_KEY_BASENAMES or name in _CREDENTIAL_BASENAMES:
  833. return True
  834. if '.' not in name and name.endswith(_PRIVATE_KEY_NAME_SUFFIXES):
  835. return True
  836. return False
  837. # Path tokens that bump a file's review priority when a commit exceeds
  838. # MAX_DIFF_FILES and we have to pick a subset. These are exactly the surfaces
  839. # single-shot and agentic reviews disagree on most (auth, routing, IPC,
  840. # subprocess, deserialization). Matched as lowercase substrings against the
  841. # path; not regex — keep it cheap.
  842. _SECURITY_RISK_PATH_TOKENS = (
  843. "auth", "login", "session", "token", "secret", "credential", "perm",
  844. "acl", "rbac", "iam", "policy",
  845. "route", "handler", "controller", "endpoint", "api/", "/api", "gateway",
  846. "middleware", "view",
  847. "exec", "subprocess", "shell", "spawn", "command",
  848. "client", "request", "fetch", "http", "url",
  849. "serialize", "pickle", "yaml", "parse", "deser",
  850. # Short tokens that would substring-match unrelated names (`format`,
  851. # `transform`, `sandbox`, `platform`) are intentionally omitted —
  852. # `sql`/`query` already cover the DB surface.
  853. "sql", "query",
  854. )
  855. # Suffixes that pass _is_reviewable_source but are almost always low-signal
  856. # in large scaffolds — generated clients, migrations, test fixtures, config
  857. # shims. These go to the BACK of the priority sort, not dropped outright.
  858. _LOW_PRIORITY_SUFFIXES = (
  859. ".gen.ts", ".gen.tsx", ".generated.ts", "_gen.py",
  860. ".test.ts", ".test.tsx", ".test.py", ".spec.ts", ".spec.js",
  861. ".config.js", ".config.ts", ".config.mjs", ".config.cjs",
  862. )
  863. _LOW_PRIORITY_PATH_TOKENS = (
  864. "/migrations/", "/alembic/versions/", "/__tests__/", "/fixtures/",
  865. )
  866. def _prioritize_diff_files(diff_files, cap):
  867. """When `diff_files` exceeds `cap`, return the top-`cap` by security
  868. relevance plus the count dropped. Otherwise return (diff_files, 0).
  869. Score = (risk_tokens_in_path, not_low_priority, added_lines). The
  870. added-lines proxy is `content.count('\\n+')` which counts diff additions
  871. cheaply without re-parsing hunks. This is a heuristic, not a guarantee —
  872. the goal is to review the likely-dangerous subset of an over-cap diff
  873. instead of reviewing nothing. Diffs that exceed the cap are typically
  874. large multi-file scaffolds, and the cross-file source→sink vulnerabilities
  875. in them concentrate in a handful of api/client/route files.
  876. """
  877. if len(diff_files) <= cap:
  878. return diff_files, 0
  879. def _score(item):
  880. fp, content = item
  881. low = fp.lower()
  882. # Prepend "/" so leading-slash patterns in _LOW_PRIORITY_PATH_TOKENS
  883. # match top-level dirs (git diff paths are repo-root-relative, e.g.
  884. # `migrations/001.py` not `/migrations/001.py`). Same trick as
  885. # _is_reviewable_source.
  886. low_slashed = "/" + low
  887. risk = sum(1 for t in _SECURITY_RISK_PATH_TOKENS if t in low)
  888. low_prio = (
  889. fp.endswith(_LOW_PRIORITY_SUFFIXES)
  890. or any(t in low_slashed for t in _LOW_PRIORITY_PATH_TOKENS)
  891. )
  892. # added_lines: count('\n+') over-counts by including '+++' header and
  893. # any literal '+' at line start in context, but it's a consistent
  894. # ordinal across files in the same diff which is all we need.
  895. added = content.count("\n+")
  896. return (risk, not low_prio, added)
  897. ranked = sorted(diff_files, key=_score, reverse=True)
  898. return ranked[:cap], len(diff_files) - cap
  899. def _is_reviewable_source(file_path):
  900. # Normalize for component matching: a path like `.next/x.js` or
  901. # `pkg/node_modules/y.ts` should both be excluded; matching against
  902. # `'/' + path` lets each pattern be checked as `'/' + p in '/' + path`
  903. # without false-positiving on `rebuild/` matching `build/`.
  904. norm = "/" + file_path.replace("\\", "/")
  905. if any(("/" + p) in norm for p in SKIP_PATH_PATTERNS):
  906. return False
  907. if _skip_secret_shaped(file_path):
  908. return False
  909. if file_path.endswith(SKIP_FILE_SUFFIXES):
  910. return False
  911. ext = os.path.splitext(file_path)[1].lower()
  912. if ext in SOURCE_CODE_EXTENSIONS:
  913. return True
  914. base = os.path.basename(file_path).lower()
  915. # Accept dot-suffixed variants too: `Dockerfile.dev`, `Makefile.am`,
  916. # `Jenkinsfile.release`. splitext gives ext='.dev'/'.am' for these so they
  917. # miss both the extension check and the exact-basename check otherwise.
  918. if base in SOURCE_CODE_BASENAMES \
  919. or base.split(".", 1)[0] in SOURCE_CODE_BASENAMES:
  920. return True
  921. # Extensionless files default to reviewable unless they're known
  922. # plain-text metadata or dotfiles. Covers shebang scripts under bin/ or
  923. # scripts/ (`deploy`, `run-canary`, `entrypoint`) which carry
  924. # shell-injection surface but were previously filtered out — the largest
  925. # remaining false-negative class for extensionless files. Dotfiles (`.gitignore`,
  926. # `.nvmrc`, `.env`) are config, not code; `.bashrc`-style runnables are
  927. # rare in repos and not worth the noise. The deny-list is prefix-aware on
  928. # `-`/`_` so dual-license / i18n variants (`LICENSE-MIT`, `README-CN`)
  929. # don't fall through as source.
  930. if ext == "" and not base.startswith("."):
  931. if any(base == x or base.startswith(x + "-") or base.startswith(x + "_")
  932. for x in NON_SOURCE_EXTENSIONLESS_BASENAMES):
  933. return False
  934. return True
  935. return False
  936. # A file's section starts at a `diff --git ` line. Inside a section every line
  937. # of file content carries a `+`, `-` or space prefix, so only a real header can
  938. # start a line with it; source text that merely contains the words cannot.
  939. _DIFF_SECTION_RE = re.compile(r'^diff --git ', re.MULTILINE)
  940. _UNQUOTED_HEADER_RE = re.compile(r'^a/(.+?) b/(.+)$')
  941. _C_ESCAPES = {
  942. 'a': b'\a', 'b': b'\b', 'f': b'\f', 'n': b'\n', 'r': b'\r', 't': b'\t',
  943. 'v': b'\v', '\\': b'\\', '"': b'"',
  944. }
  945. def _read_quoted_path(text, start):
  946. """Decode the C-quoted path whose opening quote is at text[start].
  947. Returns (path, index after the closing quote), or None if unterminated."""
  948. out = bytearray()
  949. i = start + 1
  950. while i < len(text):
  951. c = text[i]
  952. if c == '"':
  953. return out.decode("utf-8", errors="replace"), i + 1
  954. if c == '\\' and i + 1 < len(text):
  955. n = text[i + 1]
  956. if n in _C_ESCAPES:
  957. out += _C_ESCAPES[n]
  958. i += 2
  959. continue
  960. octal = re.match(r'[0-7]{1,3}', text[i + 1:i + 4])
  961. if octal:
  962. out.append(int(octal.group(0), 8) & 0xFF)
  963. i += 1 + len(octal.group(0))
  964. continue
  965. out += c.encode("utf-8")
  966. i += 1
  967. return None
  968. def _parse_diff_header(header):
  969. """(old_path, new_path) from the text after `diff --git `, or None.
  970. core.quotePath=false keeps non-ASCII names raw, but git still C-quotes a
  971. name that contains a double quote, a backslash or a control character:
  972. `"a/we\\"ird.py" "b/we\\"ird.py"`. Either side can be quoted on its own.
  973. """
  974. old = new = None
  975. if header.startswith('"'):
  976. first = _read_quoted_path(header, 0)
  977. if not first:
  978. return None
  979. old, end = first
  980. rest = header[end:].lstrip(' ')
  981. if rest.startswith('"'):
  982. second = _read_quoted_path(rest, 0)
  983. new = second[0] if second else None
  984. else:
  985. new = rest
  986. elif header.endswith('"') and ' "b/' in header:
  987. split_at = header.index(' "b/')
  988. old = header[:split_at]
  989. second = _read_quoted_path(header, split_at + 1)
  990. new = second[0] if second else None
  991. else:
  992. m = _UNQUOTED_HEADER_RE.match(header)
  993. return (m.group(1), m.group(2)) if m else None
  994. if not old or not new or not old.startswith('a/') or not new.startswith('b/'):
  995. return None
  996. return old[2:], new[2:]
  997. # Section headers that could not be parsed during this hook run; that file is
  998. # left out of the review. The Stop hook reports the count so a header shape
  999. # this parser misses shows up in telemetry instead of passing silently.
  1000. _unparsed_diff_headers = set()
  1001. def _iter_diff_sections(diff_output):
  1002. """Yield (new_path, old_path, lines after the header line) per file section."""
  1003. for section in _DIFF_SECTION_RE.split(diff_output)[1:]:
  1004. lines = section.split('\n')
  1005. paths = _parse_diff_header(lines[0])
  1006. if paths:
  1007. yield paths[1] or paths[0], paths[0], lines[1:]
  1008. else:
  1009. _unparsed_diff_headers.add(lines[0])
  1010. def extract_file_paths_from_diff(diff_output):
  1011. """
  1012. Extract file paths from unified diff output (without content).
  1013. Only includes files with source code extensions.
  1014. """
  1015. if not diff_output or not diff_output.strip():
  1016. return []
  1017. return [
  1018. file_path for file_path, old_path, _ in _iter_diff_sections(diff_output)
  1019. if _is_reviewable_source(file_path) and not _skip_secret_shaped(old_path)
  1020. ]
  1021. def parse_diff_into_files(diff_output):
  1022. """
  1023. Parse unified diff output into a list of (file_path, diff_content) tuples.
  1024. Only includes files with source code extensions.
  1025. """
  1026. if not diff_output or not diff_output.strip():
  1027. return []
  1028. files = []
  1029. for file_path, old_path, lines in _iter_diff_sections(diff_output):
  1030. # Filter to source code files only. A rename away from a secret-shaped
  1031. # name still carries the secret's content, so the old name counts too.
  1032. if not _is_reviewable_source(file_path) or _skip_secret_shaped(old_path):
  1033. continue
  1034. # Extract the diff content (from first @@ onwards)
  1035. diff_lines = []
  1036. in_hunks = False
  1037. for line in lines:
  1038. if line.startswith('@@'):
  1039. in_hunks = True
  1040. if in_hunks:
  1041. diff_lines.append(line)
  1042. if diff_lines:
  1043. files.append((file_path, '\n'.join(diff_lines)))
  1044. return files
  1045. def filter_preexisting_from_diff(diff_files, cwd, baseline_sha):
  1046. """
  1047. Filter out pre-existing content from diff files.
  1048. When a file is fully rewritten (Write tool replaces entire content),
  1049. git shows all lines as removed (-) then re-added (+). This function
  1050. detects such rewrites and strips lines from the + section that also
  1051. appeared in the - section, so the LLM reviewer only sees truly new code.
  1052. """
  1053. if not baseline_sha:
  1054. return diff_files
  1055. filtered = []
  1056. for file_path, diff_content in diff_files:
  1057. lines = diff_content.split('\n')
  1058. # Collect removed and added lines (stripping the +/- prefix)
  1059. removed_lines = set()
  1060. added_lines = []
  1061. for line in lines:
  1062. if line.startswith('-') and not line.startswith('---'):
  1063. removed_lines.add(line[1:].strip())
  1064. elif line.startswith('+') and not line.startswith('+++'):
  1065. added_lines.append(line[1:].strip())
  1066. if not removed_lines:
  1067. # New file, no pre-existing content to filter
  1068. filtered.append((file_path, diff_content))
  1069. continue
  1070. # Check what fraction of added lines were pre-existing
  1071. preexisting_count = sum(1 for l in added_lines if l in removed_lines)
  1072. if preexisting_count == 0:
  1073. filtered.append((file_path, diff_content))
  1074. continue
  1075. added_lines_set = set(added_lines)
  1076. # Rebuild diff with pre-existing lines converted to context (space prefix).
  1077. # Known imprecision: .strip() matches across indentation (so reindented
  1078. # code is treated as unchanged) and the set lets one removal mask N
  1079. # additions of the same stripped text. Accepted trade-off — this filter
  1080. # exists for the full-file Write rewrite case where exact-match would
  1081. # miss everything; the diff-review prompt's previous-findings recheck
  1082. # is the backstop.
  1083. new_lines = []
  1084. for line in lines:
  1085. if line.startswith('+') and not line.startswith('+++'):
  1086. content = line[1:].strip()
  1087. if content in removed_lines:
  1088. # Convert to context line (pre-existing, not new)
  1089. new_lines.append(' ' + line[1:])
  1090. else:
  1091. new_lines.append(line)
  1092. elif line.startswith('-') and not line.startswith('---'):
  1093. content = line[1:].strip()
  1094. if content in added_lines_set:
  1095. # Skip removed lines that were re-added (they become context)
  1096. continue
  1097. else:
  1098. new_lines.append(line)
  1099. else:
  1100. new_lines.append(line)
  1101. filtered.append((file_path, '\n'.join(new_lines)))
  1102. return filtered