spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

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