gitfence 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. fence/__init__.py +3 -0
  2. fence/anchors/__init__.py +0 -0
  3. fence/anchors/fingerprint.py +69 -0
  4. fence/check/__init__.py +0 -0
  5. fence/check/classify.py +84 -0
  6. fence/check/diff.py +127 -0
  7. fence/check/engine.py +263 -0
  8. fence/cli.py +576 -0
  9. fence/config.py +209 -0
  10. fence/context.py +77 -0
  11. fence/decisions/__init__.py +0 -0
  12. fence/decisions/build.py +579 -0
  13. fence/decisions/model.py +117 -0
  14. fence/decisions/obsolete.py +121 -0
  15. fence/decisions/reasons.py +150 -0
  16. fence/decisions/relevance.py +146 -0
  17. fence/diffing/__init__.py +0 -0
  18. fence/diffing/ranges.py +25 -0
  19. fence/diffing/symbols.py +21 -0
  20. fence/eval/__init__.py +0 -0
  21. fence/eval/backtest.py +271 -0
  22. fence/git/__init__.py +0 -0
  23. fence/git/blame.py +219 -0
  24. fence/git/history.py +171 -0
  25. fence/git/log.py +60 -0
  26. fence/git/runner.py +248 -0
  27. fence/github/__init__.py +0 -0
  28. fence/github/client.py +122 -0
  29. fence/github/comment.py +50 -0
  30. fence/github/sync.py +123 -0
  31. fence/globs.py +43 -0
  32. fence/index/__init__.py +0 -0
  33. fence/index/formatting.py +53 -0
  34. fence/index/ghosts.py +143 -0
  35. fence/index/indexer.py +219 -0
  36. fence/index/store.py +389 -0
  37. fence/lang/__init__.py +118 -0
  38. fence/lang/python.py +67 -0
  39. fence/lang/treesitter.py +165 -0
  40. fence/mcp/__init__.py +0 -0
  41. fence/mcp/server.py +306 -0
  42. fence/policy/__init__.py +0 -0
  43. fence/policy/acks.py +88 -0
  44. fence/render.py +287 -0
  45. fence/report.py +103 -0
  46. fence/scan.py +50 -0
  47. fence/scoring/__init__.py +0 -0
  48. fence/scoring/calibration.py +133 -0
  49. fence/scoring/strength.py +198 -0
  50. fence/signals/__init__.py +0 -0
  51. fence/signals/catalog.py +161 -0
  52. fence/signals/code.py +69 -0
  53. fence/text.py +43 -0
  54. fence/trace.py +65 -0
  55. gitfence-0.1.0.dist-info/METADATA +389 -0
  56. gitfence-0.1.0.dist-info/RECORD +59 -0
  57. gitfence-0.1.0.dist-info/WHEEL +4 -0
  58. gitfence-0.1.0.dist-info/entry_points.txt +3 -0
  59. gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/eval/backtest.py ADDED
@@ -0,0 +1,271 @@
1
+ """Removal-Regret Backtest (D1): replay history and score Fence against what happened.
2
+
3
+ For each first-parent commit C that removes code, a removal block is **regretted**
4
+ when, within the next `window` commits, C is reverted or the removed code (by
5
+ fingerprint) is added back. Fence then checks C's diff with only the history
6
+ visible at C's parent (Context.as_of), so no future knowledge leaks into the
7
+ verdict. A block is **warned** when Fence's verdict is KEEP or VERIFY.
8
+
9
+ Regret labels are a lower bound: a warned removal that nobody restored may still
10
+ have been risky. So precision here is conservative, and the report says so.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import secrets
17
+ import statistics
18
+ import time
19
+ from dataclasses import dataclass, field
20
+
21
+ from fence.anchors.fingerprint import containment, shingles
22
+ from fence.check.diff import DiffError, FileDiff, parse_diff
23
+ from fence.check.engine import KEEP, VERIFY, check
24
+ from fence.context import Context
25
+ from fence.decisions.build import TEST_PATH
26
+ from fence.git.runner import GitError
27
+ from fence.lang import plugin_for, tokens_for
28
+
29
+ MIN_TOKENS = 8
30
+ RESTORE_CONTAINMENT = 0.8
31
+ MAX_FILES_PER_COMMIT = 50
32
+ MAX_DIFF_BYTES = 1_000_000
33
+
34
+
35
+ @dataclass
36
+ class _Commit:
37
+ sha: str
38
+ parent: str | None
39
+ time: int
40
+ diff: str
41
+ files: list[FileDiff] = field(default_factory=list)
42
+ added: dict[str, frozenset[int]] = field(default_factory=dict) # per path
43
+
44
+ @property
45
+ def all_added(self) -> frozenset[int]:
46
+ return frozenset().union(*self.added.values()) if self.added else frozenset()
47
+
48
+
49
+ @dataclass
50
+ class BlockOutcome:
51
+ sha: str
52
+ path: str
53
+ start: int
54
+ regretted: bool
55
+ regret_kind: str | None # "reverted" | "restored"
56
+ lead_days: float | None
57
+ verdict: str # KEEP | VERIFY | CLEAR
58
+ risk: int
59
+ confidence: float | None
60
+ reason: str | None
61
+ kind: str | None = None
62
+ grade: str | None = None
63
+ dtype: str | None = None
64
+ strength: int = 0
65
+ lost: int = 0
66
+ factors: tuple[str, ...] = () # risk factor ids, for error analysis
67
+
68
+
69
+ @dataclass
70
+ class BacktestReport:
71
+ repo: str
72
+ commits_evaluated: int = 0
73
+ commits_with_removals: int = 0
74
+ blocks: int = 0
75
+ regretted: int = 0
76
+ warned: int = 0
77
+ keep: int = 0
78
+ tp_warned: int = 0
79
+ tp_keep: int = 0
80
+ commits_warned: int = 0
81
+ latencies: list[float] = field(default_factory=list)
82
+ leads: list[float] = field(default_factory=list)
83
+ outcomes: list[BlockOutcome] = field(default_factory=list)
84
+ errors: int = 0
85
+ seconds: float = 0.0
86
+
87
+ @staticmethod
88
+ def _ratio(a: int, b: int) -> float | None:
89
+ return round(a / b, 3) if b else None
90
+
91
+ def summary(self) -> dict:
92
+ return {
93
+ "repo": self.repo,
94
+ "commits_evaluated": self.commits_evaluated,
95
+ "removal_blocks": self.blocks,
96
+ "regretted_blocks": self.regretted,
97
+ "warned_blocks": self.warned,
98
+ "keep_blocks": self.keep,
99
+ "precision_warn": self._ratio(self.tp_warned, self.warned),
100
+ "recall_warn": self._ratio(self.tp_warned, self.regretted),
101
+ "precision_keep": self._ratio(self.tp_keep, self.keep),
102
+ "recall_keep": self._ratio(self.tp_keep, self.regretted),
103
+ "warned_commits_per_1k": (round(1000 * self.commits_warned / self.commits_evaluated, 1)
104
+ if self.commits_evaluated else None),
105
+ "warning_blocks_per_1k_commits": (round(1000 * self.warned / self.commits_evaluated, 1)
106
+ if self.commits_evaluated else None),
107
+ "median_check_seconds": (round(statistics.median(self.latencies), 3)
108
+ if self.latencies else None),
109
+ # Nearest-rank percentile: never below the median, even for small samples.
110
+ "p90_check_seconds": (round(sorted(self.latencies)[
111
+ min(len(self.latencies) - 1, math.ceil(0.9 * len(self.latencies)) - 1)], 3)
112
+ if self.latencies else None),
113
+ "median_lead_days": round(statistics.median(self.leads), 1) if self.leads else None,
114
+ "calibration": self.calibration(),
115
+ "calibration_monotonic": self.calibration_monotonic(),
116
+ "errors": self.errors,
117
+ "seconds": round(self.seconds, 1),
118
+ }
119
+
120
+ def calibration(self) -> list[dict]:
121
+ """Observed regret rate of ALL blocks per bin of the shipped calibration table.
122
+
123
+ On repos the table was not fitted on, this is the held-out check: rates should
124
+ rise with the bins, and the observed rate should be near the table's.
125
+ """
126
+ from fence.scoring import calibration
127
+
128
+ bins, _ = calibration.load()
129
+ out = []
130
+ for b in bins:
131
+ xs = [o for o in self.outcomes if b.risk_min <= o.risk <= b.risk_max]
132
+ k = sum(o.regretted for o in xs)
133
+ out.append({"risk": f"{b.risk_min}-{b.risk_max}", "table_rate": round(b.rate, 4),
134
+ "blocks": len(xs), "regretted": k,
135
+ "observed_rate": self._ratio(k, len(xs))})
136
+ return out
137
+
138
+ def calibration_monotonic(self) -> bool | None:
139
+ rates = [c["observed_rate"] for c in self.calibration() if c["blocks"]]
140
+ if len(rates) < 2:
141
+ return None
142
+ return all(a < b for a, b in zip(rates, rates[1:], strict=False))
143
+
144
+
145
+ def _stream_commits(ctx: Context, rev: str, limit: int) -> list[_Commit]:
146
+ nonce = secrets.token_hex(6)
147
+ marker = f"\x1e{nonce}"
148
+ args = ["log", "--first-parent", "--diff-merges=first-parent", "-p", "--unified=0",
149
+ "--no-color", "--no-ext-diff", "--no-textconv", "-M50%", "--src-prefix=a/",
150
+ "--dst-prefix=b/", f"--max-count={limit}", f"--format=%x1e{nonce}%H %P%n%ct",
151
+ ctx.repo.verify_ref(rev), "--"]
152
+ text = "".join(ctx.repo.stream(args, timeout=ctx.cfg.performance.log_timeout_seconds))
153
+ commits = []
154
+ for rec in text.split(marker):
155
+ if not rec.strip():
156
+ continue
157
+ head, _, rest = rec.partition("\n")
158
+ ts, _, diff = rest.partition("\n")
159
+ parts = head.split()
160
+ if not parts or not ts.strip().isdigit():
161
+ continue
162
+ commits.append(_Commit(parts[0], parts[1] if len(parts) > 1 else None, int(ts),
163
+ diff if len(diff) <= MAX_DIFF_BYTES else ""))
164
+ commits.reverse() # oldest first
165
+ for c in commits:
166
+ try:
167
+ c.files = parse_diff(c.diff) if c.diff else []
168
+ except DiffError:
169
+ c.files = []
170
+ for f in c.files:
171
+ if f.added_lines:
172
+ c.added[f.path] = shingles(tokens_for(f.path, "\n".join(f.added_lines)))
173
+ return commits
174
+
175
+
176
+ def backtest(ctx: Context, *, rev: str = "HEAD", max_commits: int = 300, window: int = 200,
177
+ deep: bool = True, progress=None) -> BacktestReport:
178
+ started = time.monotonic()
179
+ rep = BacktestReport(repo=str(ctx.repo.root))
180
+ commits = _stream_commits(ctx, rev, max_commits + window)
181
+ # The oldest `max_commits` of the range, so each has up to `window` later commits in
182
+ # which a regret can show up. The newest ones have shorter windows (right-censored).
183
+ evaluate = commits[:max_commits]
184
+ index_of = {c.sha: i for i, c in enumerate(commits)}
185
+ for n, c in enumerate(evaluate):
186
+ if progress and n % 25 == 0:
187
+ progress(n, len(evaluate))
188
+ rep.commits_evaluated += 1
189
+ if not c.parent or not c.files or len(c.files) > MAX_FILES_PER_COMMIT:
190
+ continue
191
+ row = ctx.store.commit(c.sha)
192
+ if row and (row.format_only or row.bulk):
193
+ continue
194
+ blocks = _removal_blocks(c)
195
+ if not blocks:
196
+ continue
197
+ rep.commits_with_removals += 1
198
+ i = index_of[c.sha]
199
+ later = commits[i + 1 : i + 1 + window]
200
+ reverted_by = set(ctx.store.reverts_of(c.sha))
201
+ labels = {}
202
+ for path, b, sh in blocks:
203
+ kind, lead = None, None
204
+ for lc in later:
205
+ if lc.sha in reverted_by:
206
+ kind, lead = "reverted", (lc.time - c.time) / 86400
207
+ break
208
+ # Same file only: boilerplate re-added elsewhere is not a restoration.
209
+ if containment(sh, lc.added.get(path, frozenset())) >= RESTORE_CONTAINMENT:
210
+ kind, lead = "restored", (lc.time - c.time) / 86400
211
+ break
212
+ labels[(path, b.start)] = (kind, lead)
213
+
214
+ ctx.as_of = ctx.store.commit(c.parent).committer_time if ctx.store.commit(c.parent) \
215
+ else c.time - 1
216
+ t0 = time.monotonic()
217
+ try:
218
+ res = check(ctx, diff_text=c.diff, base=c.parent, deep=deep, include_clear=True)
219
+ except (DiffError, GitError):
220
+ rep.errors += 1
221
+ continue
222
+ finally:
223
+ ctx.as_of = None
224
+ rep.latencies.append(time.monotonic() - t0)
225
+ by_block = {(f.path, f.start): f for f in res.findings}
226
+ warned_here = False
227
+ for (path, start), (kind, lead) in labels.items():
228
+ f = by_block.get((path, start))
229
+ verdict = f.verdict if f and f.verdict in (KEEP, VERIFY) else "CLEAR"
230
+ o = BlockOutcome(c.sha, path, start, kind is not None, kind, lead, verdict,
231
+ f.risk.value if f else 0, f.risk.confidence if f else None,
232
+ f.decision.reason_text if f else None,
233
+ f.kind if f else None, f.decision.grade if f else None,
234
+ f.decision.type if f else None,
235
+ f.decision.strength.value if f else 0,
236
+ len(f.lost_signals) if f else 0,
237
+ tuple(x.id for x in f.risk.factors) if f else ())
238
+ rep.outcomes.append(o)
239
+ rep.blocks += 1
240
+ rep.regretted += o.regretted
241
+ if verdict in (KEEP, VERIFY):
242
+ warned_here = True
243
+ rep.warned += 1
244
+ rep.tp_warned += o.regretted
245
+ if o.regretted and lead is not None:
246
+ rep.leads.append(lead)
247
+ if verdict == KEEP:
248
+ rep.keep += 1
249
+ rep.tp_keep += o.regretted
250
+ rep.commits_warned += warned_here
251
+ rep.seconds = time.monotonic() - started
252
+ return rep
253
+
254
+
255
+ def _removal_blocks(c: _Commit):
256
+ out = []
257
+ same_commit_added = c.all_added
258
+ for f in c.files:
259
+ # Source code only: tests, docs and data are not fences.
260
+ if (f.old_path is None or f.binary or plugin_for(f.old_path) is None
261
+ or TEST_PATH.search(f.old_path)):
262
+ continue
263
+ for b in f.blocks:
264
+ toks = tokens_for(f.old_path, "\n".join(t for _, t in b.removed))
265
+ if len(toks) < MIN_TOKENS:
266
+ continue
267
+ sh = shingles(toks)
268
+ if containment(sh, same_commit_added) >= RESTORE_CONTAINMENT:
269
+ continue # moved or rewritten in place, not removed
270
+ out.append((f.old_path, b, sh))
271
+ return out
fence/git/__init__.py ADDED
File without changes
fence/git/blame.py ADDED
@@ -0,0 +1,219 @@
1
+ """`git blame` against an explicit base revision, parsed from --line-porcelain.
2
+
3
+ Two things here are security decisions, not style:
4
+ - `.git-blame-ignore-revs` is read from the BASE revision. In CI the working tree is
5
+ the PR head, so reading it from disk would let a PR hide the commit that explains
6
+ the code it deletes.
7
+ - `--ignore-revs-file=` (empty) is always passed first. It clears any list git would
8
+ otherwise load from the repo's `blame.ignoreRevsFile` config, which points at the
9
+ working tree.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+
18
+ from fence.git.runner import GitError, Repo, UnsafeInput
19
+
20
+ IGNORE_REVS_PATH = ".git-blame-ignore-revs"
21
+ MAX_IGNORE_REVS = 500
22
+ MAX_RANGES = 200
23
+ MAX_LINES = 5000
24
+
25
+ _SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
26
+ _HEADER = re.compile(r"^([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\d+)(?: \d+)?$")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BlameLine:
31
+ sha: str
32
+ orig_line: int # line number in the commit that introduced it
33
+ final_line: int # line number at the base revision
34
+ author: str
35
+ author_mail: str
36
+ author_time: int # unix seconds
37
+ author_tz: str
38
+ summary: str
39
+ filename: str # path in `sha`; differs from the input path when -C found a copy
40
+ previous_sha: str | None # None on a root commit
41
+ previous_path: str | None
42
+ boundary: bool
43
+ content: str
44
+ # git 2.43 does not mark lines it could not move past an ignored commit, even
45
+ # with blame.markUnblamableLines set, so we derive it: the line still points
46
+ # at a commit we asked git to ignore.
47
+ ignored: bool = False
48
+
49
+
50
+ _IGNORE_CACHE: dict[tuple[str, str], list[str]] = {}
51
+
52
+
53
+ def read_ignore_revs(repo: Repo, base_sha: str) -> list[str]:
54
+ """Full SHAs listed in .git-blame-ignore-revs at `base_sha`. Missing file -> [].
55
+
56
+ Memoized per (repo, commit): a commit's tree never changes. Returns a copy.
57
+ """
58
+ key = (str(repo.root), base_sha)
59
+ if key not in _IGNORE_CACHE:
60
+ if len(_IGNORE_CACHE) > 10_000:
61
+ _IGNORE_CACHE.clear()
62
+ _IGNORE_CACHE[key] = _read_ignore_revs(repo, base_sha)
63
+ return list(_IGNORE_CACHE[key])
64
+
65
+
66
+ def _read_ignore_revs(repo: Repo, base_sha: str) -> list[str]:
67
+ listed = repo.run(["ls-tree", "--name-only", base_sha, "--", IGNORE_REVS_PATH]).strip()
68
+ if not listed:
69
+ return []
70
+ text = repo.read_blob(base_sha, IGNORE_REVS_PATH, max_bytes=100_000)
71
+ revs: list[str] = []
72
+ for raw in text.split("\n"):
73
+ line = raw.split("#", 1)[0].strip().lower()
74
+ # Abbreviated SHAs and ref names are skipped: they could resolve differently
75
+ # over time, and the file is repository text, so it is untrusted.
76
+ if _SHA.fullmatch(line) and line not in revs:
77
+ revs.append(line)
78
+ if len(revs) >= MAX_IGNORE_REVS:
79
+ break
80
+ return revs
81
+
82
+
83
+ def blame(
84
+ repo: Repo,
85
+ base: str,
86
+ path: str,
87
+ ranges: Sequence[tuple[int, int]],
88
+ *,
89
+ extra_ignore: Sequence[str] = (),
90
+ ) -> list[BlameLine]:
91
+ """Blame the 1-based inclusive `ranges` of `path` at `base`. One git call per file.
92
+
93
+ `extra_ignore` adds full SHAs to skip, such as formatting-only commits found by
94
+ the indexer. They come from Fence's own analysis, not from repository text.
95
+ """
96
+ base_sha = repo.verify_ref(base)
97
+ rel = repo.safe_relpath(path)
98
+ _check_ranges(ranges)
99
+ ignore = read_ignore_revs(repo, base_sha)
100
+ for sha in extra_ignore:
101
+ if _SHA.fullmatch(sha) and sha not in ignore and len(ignore) < MAX_IGNORE_REVS * 2:
102
+ ignore.append(sha)
103
+
104
+ args = ["blame", "-w", "-C", "--line-porcelain", "--ignore-revs-file="]
105
+ for sha in ignore:
106
+ args += ["--ignore-rev", sha]
107
+ for start, end in ranges:
108
+ args += ["-L", f"{start},{end}"]
109
+ args += [base_sha, "--", rel]
110
+
111
+ lines = parse_line_porcelain(repo.run(args))
112
+ expected = _distinct_line_count(ranges)
113
+ if len(lines) != expected:
114
+ raise GitError(f"blame returned {len(lines)} lines, expected {expected}")
115
+ ignored = set(ignore)
116
+ return [_with_ignored(bl, bl.sha in ignored) for bl in lines]
117
+
118
+
119
+ def parse_line_porcelain(output: str) -> list[BlameLine]:
120
+ """Parse `git blame --line-porcelain`. Raises GitError on malformed or truncated output."""
121
+ result: list[BlameLine] = []
122
+ fields: dict[str, str] | None = None
123
+ header: tuple[str, int, int] | None = None
124
+ # Split on "\n" only: str.splitlines() would also split on \x0b, \x1c, 
 etc.
125
+ # that can legitimately appear inside a line of code.
126
+ rows = output.split("\n")
127
+ if rows and rows[-1] == "":
128
+ rows.pop()
129
+ for row in rows:
130
+ if header is None:
131
+ m = _HEADER.match(row)
132
+ if not m:
133
+ raise GitError(f"unexpected blame header: {row[:80]!r}")
134
+ header = (m.group(1), int(m.group(2)), int(m.group(3)))
135
+ fields = {}
136
+ continue
137
+ if fields is None: # unreachable: always set together with header
138
+ raise GitError("blame parser lost its record state")
139
+ if row.startswith("\t"):
140
+ result.append(_build(header, fields, row[1:]))
141
+ header, fields = None, None
142
+ continue
143
+ key, _, value = row.partition(" ")
144
+ if key == "boundary":
145
+ fields["boundary"] = "1"
146
+ elif key in _KEPT_KEYS:
147
+ fields[key] = value
148
+ # committer-*, unknown keys: skipped on purpose.
149
+ if header is not None:
150
+ raise GitError("truncated blame output: header without content line")
151
+ return result
152
+
153
+
154
+ _KEPT_KEYS = {
155
+ "author",
156
+ "author-mail",
157
+ "author-time",
158
+ "author-tz",
159
+ "summary",
160
+ "filename",
161
+ "previous",
162
+ }
163
+
164
+
165
+ def _build(header: tuple[str, int, int], f: dict[str, str], content: str) -> BlameLine:
166
+ sha, orig, final = header
167
+ for required in ("author", "author-time", "filename"):
168
+ if required not in f:
169
+ raise GitError(f"blame record for {sha[:12]} is missing {required!r}")
170
+ prev_sha = prev_path = None
171
+ if "previous" in f:
172
+ prev_sha, _, prev_path = f["previous"].partition(" ")
173
+ if not _SHA.fullmatch(prev_sha) or not prev_path:
174
+ raise GitError(f"malformed 'previous' in blame record for {sha[:12]}")
175
+ try:
176
+ author_time = int(f["author-time"])
177
+ except ValueError:
178
+ raise GitError(f"malformed author-time for {sha[:12]}") from None
179
+ return BlameLine(
180
+ sha=sha,
181
+ orig_line=orig,
182
+ final_line=final,
183
+ author=f["author"],
184
+ author_mail=f.get("author-mail", "").strip("<>"),
185
+ author_time=author_time,
186
+ author_tz=f.get("author-tz", ""),
187
+ summary=f.get("summary", ""),
188
+ filename=f["filename"],
189
+ previous_sha=prev_sha,
190
+ previous_path=prev_path,
191
+ boundary="boundary" in f,
192
+ content=content,
193
+ )
194
+
195
+
196
+ def _with_ignored(bl: BlameLine, ignored: bool) -> BlameLine:
197
+ if not ignored:
198
+ return bl
199
+ return BlameLine(**{**bl.__dict__, "ignored": True})
200
+
201
+
202
+ def _check_ranges(ranges: Sequence[tuple[int, int]]) -> None:
203
+ if not ranges:
204
+ raise UnsafeInput("no line ranges given")
205
+ if len(ranges) > MAX_RANGES:
206
+ raise UnsafeInput(f"too many ranges ({len(ranges)} > {MAX_RANGES})")
207
+ for start, end in ranges:
208
+ if not (isinstance(start, int) and isinstance(end, int)) or start < 1 or end < start:
209
+ raise UnsafeInput(f"bad line range: {start},{end}")
210
+ if _distinct_line_count(ranges) > MAX_LINES:
211
+ raise UnsafeInput(f"too many lines to blame (> {MAX_LINES})")
212
+
213
+
214
+ def _distinct_line_count(ranges: Sequence[tuple[int, int]]) -> int:
215
+ # git merges overlapping -L ranges, so count distinct lines, not the sum.
216
+ seen: set[int] = set()
217
+ for start, end in ranges:
218
+ seen.update(range(start, end + 1))
219
+ return len(seen)
fence/git/history.py ADDED
@@ -0,0 +1,171 @@
1
+ """Stream `git log --raw --numstat -z` and parse it into commits with file changes.
2
+
3
+ Commit messages are untrusted and may contain any byte except NUL, including our
4
+ field separators. So every marker carries a random per-run nonce: a message
5
+ cannot forge a record boundary it cannot predict.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import secrets
12
+ from collections.abc import Iterable, Iterator
13
+ from dataclasses import dataclass, field
14
+
15
+ from fence.git.runner import GitError, Repo
16
+
17
+ _SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
18
+
19
+
20
+ @dataclass
21
+ class RawChange:
22
+ path: str
23
+ old_path: str | None
24
+ status: str # A C D M R T U X (git --raw)
25
+ similarity: int | None
26
+ added: int | None = None # None for binary files
27
+ deleted: int | None = None
28
+
29
+
30
+ @dataclass
31
+ class LogEntry:
32
+ sha: str
33
+ parents: tuple[str, ...]
34
+ author_name: str
35
+ author_email: str
36
+ author_time: int
37
+ committer_time: int
38
+ subject: str
39
+ body: str
40
+ changes: list[RawChange] = field(default_factory=list)
41
+
42
+
43
+ class LogFormat:
44
+ def __init__(self) -> None:
45
+ nonce = secrets.token_hex(8)
46
+ self.start = f"\x1e{nonce}S"
47
+ self.sep = f"\x1f{nonce}F"
48
+ self.end = f"\x1d{nonce}E"
49
+ fields = ["%H", "%P", "%an", "%ae", "%at", "%ct", "%s", "%b"]
50
+ f_sep = f"%x1f{nonce}F"
51
+ self.arg = f"--format=%x1e{nonce}S" + f_sep.join(fields) + f"%x1d{nonce}E"
52
+
53
+
54
+ def log_args(fmt: LogFormat, *, rename_threshold: int = 50) -> list[str]:
55
+ return [
56
+ "log",
57
+ "--no-color",
58
+ "--no-ext-diff",
59
+ "--no-textconv",
60
+ "--diff-merges=off", # merges carry no diff; their PR mapping comes from the subject
61
+ "--raw",
62
+ "--numstat",
63
+ "-z",
64
+ "--no-abbrev",
65
+ f"-M{rename_threshold}%",
66
+ fmt.arg,
67
+ ]
68
+
69
+
70
+ def iter_log(
71
+ repo: Repo, revs: Iterable[str], *, fmt: LogFormat | None = None, extra: Iterable[str] = (),
72
+ rename_threshold: int = 50, timeout: float = 600.0,
73
+ ) -> Iterator[LogEntry]:
74
+ """Yield commits reachable from `revs` (already-validated SHAs or `--not`), oldest first."""
75
+ fmt = fmt or LogFormat()
76
+ args = [*log_args(fmt, rename_threshold=rename_threshold), "--reverse", *extra, *revs, "--"]
77
+ yield from parse_stream(repo.stream(args, timeout=timeout), fmt)
78
+
79
+
80
+ def parse_stream(chunks: Iterable[str], fmt: LogFormat) -> Iterator[LogEntry]:
81
+ buf = ""
82
+ for chunk in chunks:
83
+ buf += chunk
84
+ # Everything before the last start marker is complete.
85
+ last = buf.rfind(fmt.start)
86
+ if last <= 0:
87
+ continue
88
+ complete, buf = buf[:last], buf[last:]
89
+ yield from _parse_records(complete, fmt)
90
+ if buf:
91
+ yield from _parse_records(buf, fmt)
92
+
93
+
94
+ def _parse_records(text: str, fmt: LogFormat) -> Iterator[LogEntry]:
95
+ for record in text.split(fmt.start):
96
+ if record.strip("\x00\n") == "":
97
+ continue
98
+ yield parse_record(record, fmt)
99
+
100
+
101
+ def parse_record(record: str, fmt: LogFormat) -> LogEntry:
102
+ header, end, rest = record.partition(fmt.end)
103
+ if not end:
104
+ raise GitError("truncated git log record")
105
+ parts = header.split(fmt.sep)
106
+ if len(parts) != 8 or not _SHA.fullmatch(parts[0]):
107
+ raise GitError(f"unexpected git log header: {header[:80]!r}")
108
+ sha, parents, an, ae, at, ct, subject, body = parts
109
+ if not (at.isdigit() and ct.isdigit()):
110
+ raise GitError(f"bad timestamps in {sha[:12]}")
111
+ entry = LogEntry(
112
+ sha=sha,
113
+ parents=tuple(p for p in parents.split(" ") if p),
114
+ author_name=an,
115
+ author_email=ae,
116
+ author_time=int(at),
117
+ committer_time=int(ct),
118
+ subject=subject,
119
+ body=body.strip("\n"),
120
+ )
121
+ entry.changes = _parse_changes(rest)
122
+ return entry
123
+
124
+
125
+ def _parse_changes(rest: str) -> list[RawChange]:
126
+ tokens = rest.split("\x00")
127
+ raw: dict[str, RawChange] = {}
128
+ order: list[str] = []
129
+ i = 0
130
+ n = len(tokens)
131
+ while i < n:
132
+ tok = tokens[i]
133
+ head = tok.lstrip("\n")
134
+ if head == "":
135
+ i += 1
136
+ continue
137
+ if head.startswith(":"):
138
+ fields = head[1:].split(" ")
139
+ if len(fields) < 5 or i + 1 >= n:
140
+ raise GitError(f"malformed raw diff entry: {head[:60]!r}")
141
+ status = fields[4]
142
+ letter = status[:1]
143
+ score = int(status[1:]) if status[1:].isdigit() else None
144
+ if letter in ("R", "C"):
145
+ old, new = tokens[i + 1], tokens[i + 2]
146
+ i += 3
147
+ else:
148
+ old, new = None, tokens[i + 1]
149
+ i += 2
150
+ raw[new] = RawChange(new, old, letter, score)
151
+ order.append(new)
152
+ continue
153
+ cols = head.split("\t")
154
+ if len(cols) != 3:
155
+ raise GitError(f"malformed numstat entry: {head[:60]!r}")
156
+ added, deleted, path = cols
157
+ if path == "": # rename: old and new follow as separate tokens
158
+ if i + 2 >= n:
159
+ raise GitError("truncated numstat rename entry")
160
+ path = tokens[i + 2]
161
+ i += 3
162
+ else:
163
+ i += 1
164
+ change = raw.get(path)
165
+ if change is None:
166
+ change = RawChange(path, None, "M", None)
167
+ raw[path] = change
168
+ order.append(path)
169
+ change.added = int(added) if added.isdigit() else None
170
+ change.deleted = int(deleted) if deleted.isdigit() else None
171
+ return [raw[p] for p in order]