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
@@ -0,0 +1,53 @@
1
+ """Detect formatting-only commits: every touched file has identical normalized tokens
2
+ before and after. Such commits are skipped by blame (like .git-blame-ignore-revs)
3
+ and never count as evidence.
4
+
5
+ Checking every commit would cost two blob reads per file across the whole history,
6
+ so only candidates are verified: commits whose message reads like a formatting
7
+ change, or whose every file kept its line count (re-indents, quote swaps). A
8
+ formatter commit that is neither is missed; listing it in .git-blame-ignore-revs
9
+ still works.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fence.git.history import LogEntry
15
+ from fence.git.runner import GitError, Repo
16
+ from fence.lang import tokens_for
17
+ from fence.signals.catalog import signal
18
+
19
+ MAX_FILES = 200
20
+ _STYLE = signal("text.formatting").pattern
21
+
22
+
23
+ def is_candidate(entry: LogEntry) -> bool:
24
+ if len(entry.parents) != 1 or not entry.changes or len(entry.changes) > MAX_FILES:
25
+ return False
26
+ if any(c.status != "M" or c.added is None for c in entry.changes):
27
+ return False # adds, deletes, renames and binaries are not formatting
28
+ worded = bool(_STYLE and _STYLE.search(f"{entry.subject}\n{entry.body}"))
29
+ return worded or all(c.added == c.deleted for c in entry.changes)
30
+
31
+
32
+ def detect_format_only(repo: Repo, entries: list[LogEntry]) -> set[str]:
33
+ """SHAs of formatting-only commits among `entries`. Two git processes per call."""
34
+ cands = [e for e in entries if is_candidate(e)]
35
+ if not cands:
36
+ return set()
37
+ specs = [(sha, c.path) for e in cands for c in e.changes for sha in (e.parents[0], e.sha)]
38
+ try:
39
+ blobs = repo.read_blobs(specs)
40
+ except GitError:
41
+ return set()
42
+ out = set()
43
+ for e in cands:
44
+ parent = e.parents[0]
45
+ for c in e.changes:
46
+ old, new = blobs.get((parent, c.path)), blobs.get((e.sha, c.path))
47
+ if old is None or new is None or old == new:
48
+ break
49
+ if tokens_for(c.path, old) != tokens_for(c.path, new):
50
+ break
51
+ else:
52
+ out.add(e.sha)
53
+ return out
fence/index/ghosts.py ADDED
@@ -0,0 +1,143 @@
1
+ """Ghost Fences (D2): tombstones for deleted guards, and matching code that comes back.
2
+
3
+ At index time, every first-parent commit that deletes a guard-like block (guard,
4
+ lock, retry/timeout, error handling, assertion) leaves a tombstone: fingerprint,
5
+ removing commit, and the deleted lines. When a later change adds code that
6
+ matches a tombstone, Fence links the two: "this re-adds a guard removed in
7
+ <commit>". The decision the guard carried is resolved lazily, with blame at
8
+ the removing commit's parent, only when a match is shown.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import secrets
16
+ from collections.abc import Iterator
17
+ from dataclasses import dataclass
18
+
19
+ from fence.anchors.fingerprint import containment, shingles
20
+ from fence.check.classify import GUARD_SIGNALS
21
+ from fence.check.diff import DiffError, parse_diff
22
+ from fence.config import Config
23
+ from fence.git.runner import GitError, Repo
24
+ from fence.index.store import Store
25
+ from fence.lang import plugin_for, tokens_for
26
+ from fence.signals.code import match_code
27
+ from fence.text import clean
28
+
29
+ MIN_TOKENS = 8
30
+ MATCH = 0.7
31
+ MAX_PER_RUN = 5000
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Ghost:
36
+ tombstone_id: str
37
+ removed_in: str
38
+ path: str
39
+ subject: str
40
+ preview: str
41
+ similarity: float
42
+
43
+
44
+ def _commits(repo: Repo, revs: list[str], timeout: float) -> Iterator[tuple[str, str, str]]:
45
+ """(sha, subject, diff text) for first-parent commits, streamed one at a time."""
46
+ nonce = secrets.token_hex(6)
47
+ marker = f"\x1e{nonce}"
48
+ args = ["log", "--first-parent", "--diff-merges=first-parent", "-p", "--unified=0",
49
+ "--no-color", "--no-ext-diff", "--no-textconv", "-M50%", "--src-prefix=a/",
50
+ "--dst-prefix=b/", f"--format=%x1e{nonce}%H%n%s", *revs, "--"]
51
+ buf = ""
52
+ for chunk in repo.stream(args, timeout=timeout):
53
+ buf += chunk
54
+ parts = buf.split(marker)
55
+ buf = parts.pop()
56
+ for rec in parts:
57
+ if rec.strip():
58
+ yield _split(rec)
59
+ if buf.strip():
60
+ yield _split(buf)
61
+
62
+
63
+ def _split(rec: str) -> tuple[str, str, str]:
64
+ sha, _, rest = rec.partition("\n")
65
+ subject, _, diff = rest.partition("\n")
66
+ return sha.strip(), subject, diff
67
+
68
+
69
+ def scan_tombstones(repo: Repo, store: Store, cfg: Config) -> int:
70
+ """Record tombstones for commits not yet scanned. Returns how many were added."""
71
+ tip = repo.verify_ref(cfg.index.refs[0]) if cfg.index.refs else repo.verify_ref("HEAD")
72
+ done = store.get_meta("ghost_tip")
73
+ revs = [tip] + (["--not", done] if done and _exists(repo, done) else [])
74
+ if done == tip:
75
+ return 0
76
+ added = 0
77
+ rows = []
78
+ for sha, subject, diff in _commits(repo, revs, cfg.performance.log_timeout_seconds):
79
+ if len(diff) > 2_000_000:
80
+ continue
81
+ try:
82
+ files = parse_diff(diff)
83
+ except DiffError:
84
+ continue
85
+ readded = frozenset().union(*(shingles(tokens_for(f.path, "\n".join(f.added_lines)))
86
+ for f in files if f.added_lines)) if files else frozenset()
87
+ for f in files:
88
+ if f.old_path is None or plugin_for(f.old_path) is None:
89
+ continue
90
+ for b in f.blocks:
91
+ lines = [t for _, t in b.removed]
92
+ if not {s for s, _ in match_code(lines)} & GUARD_SIGNALS:
93
+ continue
94
+ toks = tokens_for(f.old_path, "\n".join(lines))
95
+ if len(toks) < MIN_TOKENS:
96
+ continue
97
+ sh = shingles(toks)
98
+ if containment(sh, readded) >= MATCH:
99
+ continue # moved, not deleted
100
+ key = f"{sha}\x00{f.old_path}\x00{b.start}"
101
+ tid = hashlib.sha256(key.encode()).hexdigest()[:16]
102
+ rows.append((tid, sha, f.old_path, None, hashlib.sha256(
103
+ "\x00".join(toks).encode()).hexdigest()[:24], json.dumps(sorted(sh)),
104
+ json.dumps({"subject": clean(subject, max_len=200), "start": b.start,
105
+ "preview": clean("\n".join(lines[:6]), max_len=400)})))
106
+ added += 1
107
+ if added >= MAX_PER_RUN:
108
+ break
109
+ with store.transaction():
110
+ store.conn.executemany("INSERT OR IGNORE INTO tombstones VALUES(?,?,?,?,?,?,?)", rows)
111
+ if added < MAX_PER_RUN:
112
+ store.set_meta("ghost_tip", tip)
113
+ return added
114
+
115
+
116
+ def _exists(repo: Repo, sha: str) -> bool:
117
+ try:
118
+ repo.run(["cat-file", "-e", f"{sha}^{{commit}}"])
119
+ return True
120
+ except GitError:
121
+ return False
122
+
123
+
124
+ def find_ghosts(store: Store, path: str, added_lines: list[str], *,
125
+ exclude_shas: frozenset[str] = frozenset()) -> list[Ghost]:
126
+ """Tombstones whose deleted code is (mostly) re-added by `added_lines`."""
127
+ if not added_lines:
128
+ return []
129
+ added = shingles(tokens_for(path, "\n".join(added_lines)))
130
+ if not added:
131
+ return []
132
+ out = []
133
+ for row in store.conn.execute("SELECT * FROM tombstones"):
134
+ if row["sha"] in exclude_shas:
135
+ continue
136
+ sh = frozenset(json.loads(row["shingles"]))
137
+ sim = containment(sh, added)
138
+ if sim >= MATCH:
139
+ rec = json.loads(row["record"])
140
+ out.append(Ghost(row["id"], row["sha"], row["path"], rec["subject"], rec["preview"],
141
+ round(sim, 2)))
142
+ out.sort(key=lambda g: (-g.similarity, g.removed_in))
143
+ return out[:5]
fence/index/indexer.py ADDED
@@ -0,0 +1,219 @@
1
+ """Incremental history indexing.
2
+
3
+ Each run indexes only commits not yet stored. If a stored ref tip is no longer an
4
+ ancestor of the current tip (force-push, rebase), the reachable set is recomputed
5
+ and commits that fell out of history are marked orphaned, not deleted: decisions
6
+ that cite them must say their evidence is unreachable.
7
+
8
+ Refs are saved only after every batch has landed, so an interrupted run simply
9
+ resumes: the next run walks the same range and skips stored commits.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+
19
+ from fence.config import Config
20
+ from fence.git.history import LogEntry, iter_log
21
+ from fence.git.runner import GitError, Repo
22
+ from fence.globs import included
23
+ from fence.index.store import CommitRow, FileChange, Store
24
+ from fence.signals.catalog import extract_text
25
+
26
+ _MERGE_PR = re.compile(r"^Merge pull request #(\d+) from ")
27
+ _SQUASH_PR = re.compile(r"\(#(\d+)\)\s*$")
28
+ _REVERT = re.compile(r"This reverts commit ([0-9a-f]{40})")
29
+
30
+ FormatDetector = Callable[[Repo, list[LogEntry]], set[str]] # -> format-only SHAs
31
+ Progress = Callable[[int], None]
32
+
33
+
34
+ @dataclass
35
+ class IndexReport:
36
+ indexed: int = 0
37
+ skipped_existing: int = 0
38
+ orphaned: int = 0
39
+ rewrite_detected: bool = False
40
+ shallow: bool = False
41
+ empty: bool = False
42
+ total: int = 0
43
+ seconds: float = 0.0
44
+ notes: list[str] = field(default_factory=list)
45
+
46
+
47
+ def pr_from_subject(subject: str) -> tuple[int | None, str | None]:
48
+ if m := _MERGE_PR.match(subject):
49
+ return int(m.group(1)), "merge-subject"
50
+ if m := _SQUASH_PR.search(subject):
51
+ return int(m.group(1)), "squash-subject"
52
+ return None, None
53
+
54
+
55
+ def reverted_sha(body: str) -> str | None:
56
+ m = _REVERT.search(body)
57
+ return m.group(1) if m else None
58
+
59
+
60
+ def resolve_tips(repo: Repo, refs: list[str]) -> dict[str, str]:
61
+ tips: dict[str, str] = {}
62
+ for ref in refs:
63
+ try:
64
+ tips[ref] = repo.verify_ref(ref)
65
+ except GitError:
66
+ continue # a missing ref (e.g. empty repo) is reported by the caller
67
+ return tips
68
+
69
+
70
+ def needs_index(repo: Repo, store: Store, cfg: Config) -> bool:
71
+ """True when the index is empty or a configured tip has commits it has not seen,
72
+ such as new, amended or squashed commits since the last run."""
73
+ if store.commit_count(include_orphaned=True) == 0:
74
+ return True
75
+ return any(not store.has_commit(sha) for sha in resolve_tips(repo, cfg.index.refs).values())
76
+
77
+
78
+ def is_ancestor(repo: Repo, old: str, new: str) -> bool:
79
+ try:
80
+ repo.run(["merge-base", "--is-ancestor", old, new])
81
+ return True
82
+ except GitError:
83
+ return False
84
+
85
+
86
+ def commit_exists(repo: Repo, sha: str) -> bool:
87
+ try:
88
+ repo.run(["cat-file", "-e", f"{sha}^{{commit}}"])
89
+ return True
90
+ except GitError:
91
+ return False
92
+
93
+
94
+ def index_repo(
95
+ repo: Repo,
96
+ store: Store,
97
+ cfg: Config,
98
+ *,
99
+ detect_format: FormatDetector | None = None,
100
+ progress: Progress | None = None,
101
+ ) -> IndexReport:
102
+ started = time.monotonic()
103
+ report = IndexReport()
104
+ report.shallow = repo.run(["rev-parse", "--is-shallow-repository"]).strip() == "true"
105
+ if report.shallow:
106
+ report.notes.append("shallow clone: history before the shallow boundary is not visible")
107
+
108
+ tips = resolve_tips(repo, cfg.index.refs)
109
+ if not tips:
110
+ report.empty = True
111
+ report.notes.append("no commits found for " + ", ".join(cfg.index.refs))
112
+ return report
113
+
114
+ old_refs = store.refs()
115
+ exclude_old: list[str] = []
116
+ for name, new in tips.items():
117
+ old = old_refs.get(name)
118
+ if not old or old == new:
119
+ if old:
120
+ exclude_old.append(old)
121
+ continue
122
+ if commit_exists(repo, old) and is_ancestor(repo, old, new):
123
+ exclude_old.append(old)
124
+ else:
125
+ report.rewrite_detected = True
126
+
127
+ if report.rewrite_detected:
128
+ reachable = repo.run(["rev-list", *tips.values()]).split()
129
+ with store.transaction():
130
+ report.orphaned = store.mark_orphans(reachable)
131
+ report.notes.append(
132
+ f"history was rewritten (force-push or rebase); {report.orphaned} commits orphaned"
133
+ )
134
+
135
+ extra: list[str] = []
136
+ if cfg.index.history_depth:
137
+ extra.append(f"--max-count={cfg.index.history_depth}")
138
+ if cfg.index.since_date:
139
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", cfg.index.since_date):
140
+ raise GitError(f"index.since_date must be YYYY-MM-DD, got {cfg.index.since_date!r}")
141
+ extra.append(f"--since={cfg.index.since_date}")
142
+ revs = [*dict.fromkeys(tips.values())]
143
+ if exclude_old:
144
+ revs += ["--not", *dict.fromkeys(exclude_old)]
145
+
146
+ batch: list[LogEntry] = []
147
+ for entry in iter_log(
148
+ repo, revs, extra=extra, rename_threshold=cfg.index.follow_renames_sensitivity,
149
+ timeout=cfg.performance.log_timeout_seconds,
150
+ ):
151
+ if store.has_commit(entry.sha):
152
+ report.skipped_existing += 1
153
+ continue
154
+ batch.append(entry)
155
+ if len(batch) >= cfg.index.batch_size:
156
+ _write_batch(repo, store, cfg, batch, detect_format)
157
+ report.indexed += len(batch)
158
+ batch = []
159
+ if progress:
160
+ progress(report.indexed)
161
+ if batch:
162
+ _write_batch(repo, store, cfg, batch, detect_format)
163
+ report.indexed += len(batch)
164
+ if progress:
165
+ progress(report.indexed)
166
+
167
+ with store.transaction():
168
+ for name, sha in tips.items():
169
+ store.set_ref(name, sha)
170
+ store.set_meta("last_indexed_at", str(int(time.time())))
171
+ store.set_meta("shallow", "1" if report.shallow else "0")
172
+ if report.indexed or report.rewrite_detected:
173
+ store.clear_query_cache()
174
+ report.total = store.commit_count()
175
+ report.seconds = round(time.monotonic() - started, 3)
176
+ return report
177
+
178
+
179
+ def _write_batch(
180
+ repo: Repo, store: Store, cfg: Config, batch: list[LogEntry],
181
+ detect_format: FormatDetector | None,
182
+ ) -> None:
183
+ rows: list[CommitRow] = []
184
+ changes: list[FileChange] = []
185
+ signals: list[tuple[str, str, str]] = []
186
+ reverts: list[tuple[str, str]] = []
187
+ fmt_shas = detect_format(repo, batch) if detect_format else set()
188
+ for e in batch:
189
+ pr, pr_source = pr_from_subject(e.subject)
190
+ kept = [
191
+ c for c in e.changes
192
+ if included(c.path, cfg.index.include, cfg.index.exclude)
193
+ or (c.old_path and included(c.old_path, cfg.index.include, cfg.index.exclude))
194
+ ]
195
+ format_only = bool(kept) and e.sha in fmt_shas
196
+ rows.append(
197
+ CommitRow(
198
+ sha=e.sha, parents=e.parents, author_name=e.author_name,
199
+ author_email=e.author_email, author_time=e.author_time,
200
+ committer_time=e.committer_time, subject=e.subject, body=e.body,
201
+ pr_number=pr, pr_source=pr_source, files_changed=len(e.changes),
202
+ format_only=format_only, bulk=len(e.changes) >= cfg.index.bulk_commit_files,
203
+ )
204
+ )
205
+ changes.extend(
206
+ FileChange(e.sha, c.path, c.old_path, c.status, c.similarity, c.added, c.deleted)
207
+ for c in kept
208
+ )
209
+ skip = frozenset({f"#{pr}"}) if pr else frozenset()
210
+ signals.extend((e.sha, sid, detail) for sid, detail in extract_text(
211
+ f"{e.subject}\n\n{e.body}", skip_refs=skip
212
+ ))
213
+ if target := reverted_sha(e.body):
214
+ reverts.append((e.sha, target))
215
+ with store.transaction():
216
+ store.put_commits(rows, changes)
217
+ store.put_signals(signals)
218
+ for sha, target in reverts:
219
+ store.put_revert(sha, target)