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