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.
- fence/__init__.py +3 -0
- fence/anchors/__init__.py +0 -0
- fence/anchors/fingerprint.py +69 -0
- fence/check/__init__.py +0 -0
- fence/check/classify.py +84 -0
- fence/check/diff.py +127 -0
- fence/check/engine.py +263 -0
- fence/cli.py +576 -0
- fence/config.py +209 -0
- fence/context.py +77 -0
- fence/decisions/__init__.py +0 -0
- fence/decisions/build.py +579 -0
- fence/decisions/model.py +117 -0
- fence/decisions/obsolete.py +121 -0
- fence/decisions/reasons.py +150 -0
- fence/decisions/relevance.py +146 -0
- fence/diffing/__init__.py +0 -0
- fence/diffing/ranges.py +25 -0
- fence/diffing/symbols.py +21 -0
- fence/eval/__init__.py +0 -0
- fence/eval/backtest.py +271 -0
- fence/git/__init__.py +0 -0
- fence/git/blame.py +219 -0
- fence/git/history.py +171 -0
- fence/git/log.py +60 -0
- fence/git/runner.py +248 -0
- fence/github/__init__.py +0 -0
- fence/github/client.py +122 -0
- fence/github/comment.py +50 -0
- fence/github/sync.py +123 -0
- fence/globs.py +43 -0
- fence/index/__init__.py +0 -0
- fence/index/formatting.py +53 -0
- fence/index/ghosts.py +143 -0
- fence/index/indexer.py +219 -0
- fence/index/store.py +389 -0
- fence/lang/__init__.py +118 -0
- fence/lang/python.py +67 -0
- fence/lang/treesitter.py +165 -0
- fence/mcp/__init__.py +0 -0
- fence/mcp/server.py +306 -0
- fence/policy/__init__.py +0 -0
- fence/policy/acks.py +88 -0
- fence/render.py +287 -0
- fence/report.py +103 -0
- fence/scan.py +50 -0
- fence/scoring/__init__.py +0 -0
- fence/scoring/calibration.py +133 -0
- fence/scoring/strength.py +198 -0
- fence/signals/__init__.py +0 -0
- fence/signals/catalog.py +161 -0
- fence/signals/code.py +69 -0
- fence/text.py +43 -0
- fence/trace.py +65 -0
- gitfence-0.1.0.dist-info/METADATA +389 -0
- gitfence-0.1.0.dist-info/RECORD +59 -0
- gitfence-0.1.0.dist-info/WHEEL +4 -0
- gitfence-0.1.0.dist-info/entry_points.txt +3 -0
- 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]
|