gitutil.py 45 KB

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