crapkit 0.2.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.
- crapkit/__init__.py +2 -0
- crapkit/__main__.py +5 -0
- crapkit/_pygdefer.py +86 -0
- crapkit/analyze.py +375 -0
- crapkit/cache.py +58 -0
- crapkit/churn.py +113 -0
- crapkit/churn_cache.py +108 -0
- crapkit/churn_log.py +286 -0
- crapkit/cli/__init__.py +316 -0
- crapkit/cli/_shared.py +130 -0
- crapkit/cli/admin.py +650 -0
- crapkit/cli/analyses.py +144 -0
- crapkit/cli/parser.py +384 -0
- crapkit/cli/queue.py +926 -0
- crapkit/cli/ratchet_cmds.py +172 -0
- crapkit/cli/reports.py +459 -0
- crapkit/cli/scoring.py +500 -0
- crapkit/cli/verifying.py +580 -0
- crapkit/config.py +289 -0
- crapkit/coupling.py +89 -0
- crapkit/coverage_istanbul.py +225 -0
- crapkit/coverage_py.py +87 -0
- crapkit/covstream.py +320 -0
- crapkit/diffparse.py +98 -0
- crapkit/digest.py +191 -0
- crapkit/discover.py +365 -0
- crapkit/doctor.py +308 -0
- crapkit/dup.py +179 -0
- crapkit/errors.py +18 -0
- crapkit/gitio.py +504 -0
- crapkit/hook.py +167 -0
- crapkit/junitparse.py +87 -0
- crapkit/lanes.py +373 -0
- crapkit/lizardcognitive.py +238 -0
- crapkit/mcp_server.py +167 -0
- crapkit/merge.py +77 -0
- crapkit/mutate.py +96 -0
- crapkit/mutate_pool.py +152 -0
- crapkit/override.py +94 -0
- crapkit/packet.py +343 -0
- crapkit/ratchet.py +236 -0
- crapkit/ratchet_report.py +135 -0
- crapkit/sarif.py +82 -0
- crapkit/sarifio.py +49 -0
- crapkit/scaffold.py +361 -0
- crapkit/score.py +255 -0
- crapkit/snapshot.py +51 -0
- crapkit/store.py +1066 -0
- crapkit/uncovered.py +131 -0
- crapkit/universe.py +157 -0
- crapkit/verify.py +194 -0
- crapkit/watch.py +112 -0
- crapkit/worklist.py +290 -0
- crapkit-0.2.0.dist-info/METADATA +802 -0
- crapkit-0.2.0.dist-info/RECORD +59 -0
- crapkit-0.2.0.dist-info/WHEEL +5 -0
- crapkit-0.2.0.dist-info/entry_points.txt +2 -0
- crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- crapkit-0.2.0.dist-info/top_level.txt +1 -0
crapkit/churn_cache.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The one door to churn data, with git's tree walk cached behind it.
|
|
2
|
+
|
|
3
|
+
`git log --name-only` over a year of a large repo costs 6.5s, 5.8s of which is
|
|
4
|
+
git diffing every commit's tree, and worklist, next-item and coupling each paid
|
|
5
|
+
it in full on every invocation, at an unmoved HEAD. Every one of them reaches
|
|
6
|
+
git through this module, so `.crapkit/churn-cache.json` has exactly one writer.
|
|
7
|
+
|
|
8
|
+
The key is (HEAD sha, window months, UTC date). The sha pins the history; the
|
|
9
|
+
window pins the command; the date is there because `--since=N months ago` is
|
|
10
|
+
evaluated against the wall clock, so yesterday's cache describes a window one
|
|
11
|
+
day wider than today's. Anything else is a miss, and a miss rebuilds.
|
|
12
|
+
|
|
13
|
+
A cache is disposable: unreadable, corrupt or unkeyable content reads as cold,
|
|
14
|
+
never as a crash. Uncommitted work is invisible to churn either way. The one
|
|
15
|
+
thing a sha does not pin is depth — deepening a shallow clone adds history
|
|
16
|
+
under an unmoved HEAD — and that resolves itself at the next date rollover.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .churn import FileChurn, parse_git_log_lines
|
|
26
|
+
from .churn_log import has_cache, log_lines
|
|
27
|
+
from .errors import GitError
|
|
28
|
+
from .gitio import churn_log_lines, head_commit
|
|
29
|
+
|
|
30
|
+
CACHE_NAME = "churn-cache.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _window_lines(root: Path, months: int) -> Iterator[str]:
|
|
34
|
+
"""The raw window log a map rebuild parses.
|
|
35
|
+
|
|
36
|
+
Read through the deflated log cache when one is on disk (free at an exact
|
|
37
|
+
key, a cached..HEAD range walk otherwise); straight from git when none is.
|
|
38
|
+
A map-only command never lays the log down — the commands that need its
|
|
39
|
+
per-commit structure (brief, batches, coupling) already do."""
|
|
40
|
+
if has_cache(root):
|
|
41
|
+
return log_lines(root, months)
|
|
42
|
+
return churn_log_lines(root, months)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_churn(root: Path, months: int) -> dict[str, FileChurn]:
|
|
46
|
+
"""Per-file churn for the window — from disk when the key still matches, else rebuilt."""
|
|
47
|
+
path = root / ".crapkit" / CACHE_NAME
|
|
48
|
+
key = _cache_key(root, months)
|
|
49
|
+
cached = _read_cache(path, key)
|
|
50
|
+
if cached is not None:
|
|
51
|
+
return cached
|
|
52
|
+
churn = parse_git_log_lines(_window_lines(root, months))
|
|
53
|
+
_write_cache(path, key, churn)
|
|
54
|
+
return churn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _utc_date() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cache_key(root: Path, months: int) -> dict | None:
|
|
62
|
+
"""None when HEAD is unreadable — then there is nothing safe to key on."""
|
|
63
|
+
try:
|
|
64
|
+
head = head_commit(root)
|
|
65
|
+
except GitError:
|
|
66
|
+
return None
|
|
67
|
+
return {"head": head, "months": months, "date": _utc_date()}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_cache(path: Path, key: dict | None) -> dict[str, FileChurn] | None:
|
|
71
|
+
if key is None:
|
|
72
|
+
return None
|
|
73
|
+
doc = _read_doc(path)
|
|
74
|
+
if doc is None or doc.get("key") != key:
|
|
75
|
+
return None
|
|
76
|
+
return _decode(doc.get("files"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_doc(path: Path) -> dict | None:
|
|
80
|
+
try:
|
|
81
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
return doc if isinstance(doc, dict) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _decode(files) -> dict[str, FileChurn] | None:
|
|
88
|
+
"""Rebuild the map from whatever JSON held under "files" — hence the wide except.
|
|
89
|
+
|
|
90
|
+
Weights are round(_, 4) floats, which JSON round-trips exactly, so a warm
|
|
91
|
+
read is byte-identical to the cold one it replaces.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return {p: FileChurn(int(c), int(a), float(w)) for p, (c, a, w) in files.items()}
|
|
95
|
+
except (AttributeError, TypeError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_cache(path: Path, key: dict | None, churn: dict[str, FileChurn]) -> None:
|
|
100
|
+
"""Best effort: a read-only .crapkit costs the speedup, never the command."""
|
|
101
|
+
if key is None:
|
|
102
|
+
return
|
|
103
|
+
doc = {"key": key, "files": {p: [c.commits, c.authors, c.weight] for p, c in churn.items()}}
|
|
104
|
+
try:
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(json.dumps(doc, sort_keys=True), encoding="utf-8")
|
|
107
|
+
except OSError:
|
|
108
|
+
return
|
crapkit/churn_log.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""The churn window's raw log, kept on disk deflated, and refreshed instead of rewalked.
|
|
2
|
+
|
|
3
|
+
`churn_cache` stores the DERIVED per-file map. Coupling needs the structure that
|
|
4
|
+
map threw away — which files shared a commit — so `brief` and `worklist
|
|
5
|
+
--batches` re-walked twelve months of history on every invocation: 6.9 s of git
|
|
6
|
+
diffing every commit's tree, at an unmoved HEAD, for a stream they read once.
|
|
7
|
+
Off disk the same 628k lines take 0.22 s.
|
|
8
|
+
|
|
9
|
+
This is that stream, written down. Deflated, because the log is 23 MB of highly
|
|
10
|
+
repetitive path text that compresses to 4.7 MB, and reading 4.7 MB is what makes
|
|
11
|
+
the copy worth having. The whole compressed file is read before the first line
|
|
12
|
+
goes out (its CRC is the only way to know a log is not half-written); the 23 MB
|
|
13
|
+
of text it holds never is.
|
|
14
|
+
|
|
15
|
+
Two things are stored that the served lines do not carry. The commit date (%ct)
|
|
16
|
+
rides along on every header line, so an aged-out commit can be expired without
|
|
17
|
+
asking git; and the key records the HEAD the log was built from, so a HEAD that
|
|
18
|
+
grew from it costs `git log cached..HEAD` instead of the window — 0.77 s instead
|
|
19
|
+
of 6.9 s at a day-old HEAD, for the same 628k lines. Commit date is not author
|
|
20
|
+
date: `--since` filters on the committer's clock while the recency weight uses
|
|
21
|
+
the author's, and a rebased commit has two different ones.
|
|
22
|
+
|
|
23
|
+
A cache is disposable. An unreadable, torn or unkeyable log reads as cold, never
|
|
24
|
+
as a crash, and a HEAD the cached log is not an ancestor of (a rewind, a rebase,
|
|
25
|
+
a force-push) rebuilds rather than prepends.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import codecs
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import zlib
|
|
33
|
+
from collections.abc import Iterator
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from itertools import chain
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import BinaryIO
|
|
38
|
+
|
|
39
|
+
from .errors import GitError
|
|
40
|
+
from .gitio import _git_lines, head_commit, is_ancestor
|
|
41
|
+
|
|
42
|
+
LOG_NAME = "churn-log.z"
|
|
43
|
+
LOG_FORMAT = "--format=%x01%an%x02%at%x02%ct"
|
|
44
|
+
CHUNK = 1 << 20
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_lines(root: Path, months: int) -> Iterator[str]:
|
|
48
|
+
"""The churn window's log, streamed, in the format every consumer parses.
|
|
49
|
+
|
|
50
|
+
Same lines as `gitio.churn_log_lines`, off disk whenever the key still holds.
|
|
51
|
+
"""
|
|
52
|
+
return (_shipped(line) for line in _stored_lines(root, months))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def has_cache(root: Path) -> bool:
|
|
56
|
+
"""Whether a laid-down log exists to serve or refresh from, whatever its key.
|
|
57
|
+
|
|
58
|
+
The per-file map uses this to choose its source: a map-only command must
|
|
59
|
+
never pay the log's disk footprint into being, but ignoring a log already
|
|
60
|
+
on disk would re-buy the walk the log exists to end."""
|
|
61
|
+
path = root / ".crapkit" / LOG_NAME
|
|
62
|
+
return path.is_file() and _key_path(path).is_file()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _stored_lines(root: Path, months: int) -> Iterator[str]:
|
|
66
|
+
"""The same log with its commit dates still attached — the on-disk form."""
|
|
67
|
+
path = root / ".crapkit" / LOG_NAME
|
|
68
|
+
key = _cache_key(root, months)
|
|
69
|
+
served = _cached(path, key)
|
|
70
|
+
if served is not None:
|
|
71
|
+
return served
|
|
72
|
+
return _tee(_source(root, months, path, key), path, key)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _source(root: Path, months: int, path: Path, key: dict | None) -> Iterator[str]:
|
|
76
|
+
refreshed = _refreshed(root, months, path, key)
|
|
77
|
+
if refreshed is not None:
|
|
78
|
+
return refreshed
|
|
79
|
+
return _window_log(root, months)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _shipped(line: str) -> str:
|
|
83
|
+
"""An enriched header down to the shipped `%an\\x02%at`; path lines pass through."""
|
|
84
|
+
if not line.startswith("\x01"):
|
|
85
|
+
return line
|
|
86
|
+
head, sep, _ = line.rstrip("\n").rpartition("\x02")
|
|
87
|
+
return head + "\n" if sep else line
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _commit_time(line: str) -> int:
|
|
91
|
+
"""The committer timestamp off a header line; 0 when there is none to read."""
|
|
92
|
+
raw = line.rstrip("\n").rpartition("\x02")[2]
|
|
93
|
+
return int(raw) if raw.isdigit() else 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _utc_date() -> str:
|
|
97
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cache_key(root: Path, months: int) -> dict | None:
|
|
101
|
+
"""None when HEAD is unreadable — then there is nothing safe to key on."""
|
|
102
|
+
try:
|
|
103
|
+
head = head_commit(root)
|
|
104
|
+
except GitError:
|
|
105
|
+
return None
|
|
106
|
+
return {"head": head, "months": months, "date": _utc_date()}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _key_path(path: Path) -> Path:
|
|
110
|
+
return path.with_suffix(".json")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_key(path: Path) -> dict | None:
|
|
114
|
+
try:
|
|
115
|
+
doc = json.loads(_key_path(path).read_text(encoding="utf-8"))
|
|
116
|
+
except (OSError, ValueError):
|
|
117
|
+
return None
|
|
118
|
+
return doc if isinstance(doc, dict) else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _key_fields(doc: dict) -> dict:
|
|
122
|
+
return {field: doc.get(field) for field in ("head", "months", "date")}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cached(path: Path, key: dict | None) -> Iterator[str] | None:
|
|
126
|
+
"""The stored log when it answers exactly this key, else None."""
|
|
127
|
+
stored = _read_key(path)
|
|
128
|
+
if key is None or stored is None or _key_fields(stored) != key:
|
|
129
|
+
return None
|
|
130
|
+
return _read_log(path, stored)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_log(path: Path, stored: dict) -> Iterator[str] | None:
|
|
134
|
+
"""Integrity first: the verdict on a torn log has to land before line one."""
|
|
135
|
+
try:
|
|
136
|
+
blob = path.read_bytes()
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
if len(blob) != stored.get("size") or zlib.crc32(blob) != stored.get("crc"):
|
|
140
|
+
return None
|
|
141
|
+
return _inflate(blob)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _inflate(blob: bytes) -> Iterator[str]:
|
|
145
|
+
"""Inflate a chunk at a time and hand back whole lines: 4.7 MB of deflate is
|
|
146
|
+
23 MB of text, and only the compressed side is ever resident.
|
|
147
|
+
|
|
148
|
+
An INCREMENTAL utf-8 decoder, because a deflate chunk boundary can fall in
|
|
149
|
+
the middle of a multi-byte character and author names are full of them.
|
|
150
|
+
"""
|
|
151
|
+
dec = zlib.decompressobj()
|
|
152
|
+
utf8 = codecs.getincrementaldecoder("utf-8")("replace")
|
|
153
|
+
tail = ""
|
|
154
|
+
for start in range(0, len(blob), CHUNK):
|
|
155
|
+
text = tail + utf8.decode(dec.decompress(blob[start:start + CHUNK]))
|
|
156
|
+
lines = text.split("\n")
|
|
157
|
+
tail = lines.pop()
|
|
158
|
+
yield from (line + "\n" for line in lines)
|
|
159
|
+
if tail:
|
|
160
|
+
yield tail
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _refreshed(root: Path, months: int, path: Path, key: dict | None) -> Iterator[str] | None:
|
|
164
|
+
"""The cached log carried forward to this HEAD, or None when only a walk will do."""
|
|
165
|
+
stored = _read_key(path)
|
|
166
|
+
if key is None or not _refreshable(root, stored, key):
|
|
167
|
+
return None
|
|
168
|
+
cached = _read_log(path, stored)
|
|
169
|
+
cutoff = _window_cutoff(root, months)
|
|
170
|
+
if cached is None or cutoff is None:
|
|
171
|
+
return None
|
|
172
|
+
return _within(chain(_fresh_commits(root, stored["head"], key["head"]), cached), cutoff)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _refreshable(root: Path, stored: dict | None, key: dict) -> bool:
|
|
176
|
+
"""True only for a cached log this HEAD grew from: same window, and behind us."""
|
|
177
|
+
if stored is None or stored.get("months") != key["months"]:
|
|
178
|
+
return False
|
|
179
|
+
if stored.get("head") == key["head"]:
|
|
180
|
+
return True
|
|
181
|
+
return is_ancestor(root, str(stored.get("head")), key["head"])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fresh_commits(root: Path, base: str, head: str) -> Iterator[str]:
|
|
185
|
+
"""Nothing to walk when the log already ends at this HEAD — a re-dating is free."""
|
|
186
|
+
if base == head:
|
|
187
|
+
return iter(())
|
|
188
|
+
return _range_log(root, base, head)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _within(lines: Iterator[str], cutoff: int) -> Iterator[str]:
|
|
192
|
+
"""Commit blocks below the window floor, dropped whole with their paths."""
|
|
193
|
+
keep = False
|
|
194
|
+
for line in lines:
|
|
195
|
+
if line.startswith("\x01"):
|
|
196
|
+
keep = _commit_time(line) >= cutoff
|
|
197
|
+
if keep:
|
|
198
|
+
yield line
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _tee(source: Iterator[str], path: Path, key: dict | None) -> Iterator[str]:
|
|
202
|
+
"""Stream the log out and lay the compressed copy down as the lines go past.
|
|
203
|
+
|
|
204
|
+
Written under a pid-unique .part and renamed at the end, so a reader that
|
|
205
|
+
stops early, a crash, or a second crapkit running beside this one never
|
|
206
|
+
leaves a truncated log looking valid.
|
|
207
|
+
"""
|
|
208
|
+
part = _open_part(path, key)
|
|
209
|
+
if part is None:
|
|
210
|
+
yield from source
|
|
211
|
+
return
|
|
212
|
+
comp = zlib.compressobj(1)
|
|
213
|
+
try:
|
|
214
|
+
for line in source:
|
|
215
|
+
# normalized: the seam promises lines, not their endings, and a
|
|
216
|
+
# stub that strips them would be written back as one long line.
|
|
217
|
+
part.write(comp.compress(line.rstrip("\n").encode("utf-8") + b"\n"))
|
|
218
|
+
yield line
|
|
219
|
+
part.write(comp.flush())
|
|
220
|
+
except BaseException:
|
|
221
|
+
_discard(part, path)
|
|
222
|
+
raise
|
|
223
|
+
_keep(part, path, key)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _part_path(path: Path) -> Path:
|
|
227
|
+
return path.with_suffix(f".{os.getpid()}.part")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _open_part(path: Path, key: dict | None) -> BinaryIO | None:
|
|
231
|
+
"""None when there is nothing to key on or nowhere to write: a read-only
|
|
232
|
+
.crapkit costs the speedup, never the command."""
|
|
233
|
+
if key is None:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
return _part_path(path).open("wb")
|
|
238
|
+
except OSError:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _discard(part: BinaryIO, path: Path) -> None:
|
|
243
|
+
"""Never raises: a cleanup error must not mask the failure that caused it."""
|
|
244
|
+
try:
|
|
245
|
+
part.close()
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
_part_path(path).unlink(missing_ok=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _keep(part: BinaryIO, path: Path, key: dict | None) -> None:
|
|
252
|
+
"""Rename, then key. The key is written last and carries the log's size and
|
|
253
|
+
CRC, so a log without one — or with one that describes other bytes — is cold."""
|
|
254
|
+
try:
|
|
255
|
+
part.close()
|
|
256
|
+
_part_path(path).replace(path)
|
|
257
|
+
blob = path.read_bytes()
|
|
258
|
+
stamp = {**key, "size": len(blob), "crc": zlib.crc32(blob)}
|
|
259
|
+
_key_path(path).write_text(json.dumps(stamp, sort_keys=True), encoding="utf-8")
|
|
260
|
+
except OSError:
|
|
261
|
+
_part_path(path).unlink(missing_ok=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _window_log(root: Path, months: int) -> Iterator[str]:
|
|
265
|
+
"""The whole window, from git. The expensive one."""
|
|
266
|
+
return _git_lines(root, "log", f"--since={months} months ago", LOG_FORMAT, "--name-only")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _range_log(root: Path, base: str, head: str) -> Iterator[str]:
|
|
270
|
+
"""Only what HEAD added on top of the cached log."""
|
|
271
|
+
return _git_lines(root, "log", f"{base}..{head}", LOG_FORMAT, "--name-only")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _window_cutoff(root: Path, months: int) -> int | None:
|
|
275
|
+
"""git's own reading of "N months ago": rev-parse prints the --max-age it would
|
|
276
|
+
hand rev-list, so a re-dated log expires exactly what a fresh --since would.
|
|
277
|
+
|
|
278
|
+
None when git will not answer — and then a refresh would be guessing at the
|
|
279
|
+
window, which is what the full walk is for.
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
out = "".join(_git_lines(root, "rev-parse", f"--since={months} months ago"))
|
|
283
|
+
except GitError:
|
|
284
|
+
return None
|
|
285
|
+
digits = out.strip().rpartition("=")[2]
|
|
286
|
+
return int(digits) if digits.isdigit() else None
|