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
|
@@ -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)
|