repotemplates 0.13.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.
- repotemplates/__init__.py +1 -0
- repotemplates/__main__.py +3 -0
- repotemplates/check.py +330 -0
- repotemplates/classes.py +291 -0
- repotemplates/cli.py +265 -0
- repotemplates/data/manifest.json +346 -0
- repotemplates/host.py +27 -0
- repotemplates/manifest.py +219 -0
- repotemplates/paths.py +79 -0
- repotemplates/prompts/__init__.py +1 -0
- repotemplates/prompts/install-renovatabot/SKILL.md +48 -0
- repotemplates/prompts/install-template/SKILL.md +405 -0
- repotemplates/render.py +130 -0
- repotemplates/scripts/init.sh +373 -0
- repotemplates/scripts/renovatabot-enroll.sh +193 -0
- repotemplates/template/.claude/CLAUDE.md +42 -0
- repotemplates/template/.claude/hooks/lint-typecheck.sh +42 -0
- repotemplates/template/.claude/settings.json +15 -0
- repotemplates/template/.gitattributes +1 -0
- repotemplates/template/.github/dependabot.yml +25 -0
- repotemplates/template/.github/renovate.json +30 -0
- repotemplates/template/.github/workflows/publish.yml +34 -0
- repotemplates/template/.github/workflows/renovate.yml +47 -0
- repotemplates/template/.github/workflows/test.yml +29 -0
- repotemplates/template/.gitignore +22 -0
- repotemplates/template/.planners/README.md +4 -0
- repotemplates/template/.planners/plans/.gitkeep +0 -0
- repotemplates/template/.pre-commit-config.yaml +34 -0
- repotemplates/template/.python-version +1 -0
- repotemplates/template/CHANGELOG.md +20 -0
- repotemplates/template/MODULE__NAME/__init__.py +0 -0
- repotemplates/template/MODULE__NAME/cli.py +11 -0
- repotemplates/template/MODULE__NAME/py.typed +0 -0
- repotemplates/template/README.md +1 -0
- repotemplates/template/pyproject.toml +91 -0
- repotemplates/template/tests/__init__.py +0 -0
- repotemplates/template/tests/test_MODULE__NAME.py +13 -0
- repotemplates/upgrade.py +244 -0
- repotemplates/versions.py +27 -0
- repotemplates-0.13.0.dist-info/METADATA +271 -0
- repotemplates-0.13.0.dist-info/RECORD +43 -0
- repotemplates-0.13.0.dist-info/WHEEL +4 -0
- repotemplates-0.13.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scaffold repos from repotemplates and check them against it."""
|
repotemplates/check.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Compare a repo's template-owned files with the template. Read-only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from repotemplates.classes import (
|
|
11
|
+
CONFIG,
|
|
12
|
+
GITIGNORE,
|
|
13
|
+
IGNORE,
|
|
14
|
+
PINNED,
|
|
15
|
+
attribute_gaps,
|
|
16
|
+
compare_pins,
|
|
17
|
+
ignore_gaps,
|
|
18
|
+
)
|
|
19
|
+
from repotemplates.manifest import (
|
|
20
|
+
SETTINGS,
|
|
21
|
+
SETTINGS_HOOKS,
|
|
22
|
+
DirSource,
|
|
23
|
+
Manifest,
|
|
24
|
+
file_digest,
|
|
25
|
+
file_path,
|
|
26
|
+
fingerprint,
|
|
27
|
+
)
|
|
28
|
+
from repotemplates.versions import is_prerelease, sort_key
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CLAUDE_SETTINGS",
|
|
32
|
+
"KINDS",
|
|
33
|
+
"RAISABLE_PINS",
|
|
34
|
+
"FileReport",
|
|
35
|
+
"RepoReport",
|
|
36
|
+
"StampReport",
|
|
37
|
+
"check_fleet",
|
|
38
|
+
"check_repo",
|
|
39
|
+
"latest_release",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# The stamp table, and the name it had before the rename in 0.12.0.
|
|
43
|
+
STAMP_TABLES = ("repotemplates", "proj-template")
|
|
44
|
+
CLAUDE_SETTINGS = (SETTINGS, ".claude/settings.local.json")
|
|
45
|
+
# The file classes, in report order
|
|
46
|
+
KINDS = ("hook-script", "hook-entries", "pinned", "config", "ignore")
|
|
47
|
+
# Pin verdicts that leave a pinned file behind the template. upgrade raises
|
|
48
|
+
# the raisable ones; ``differs`` (a pin that is not a version) needs a person.
|
|
49
|
+
RAISABLE_PINS = ("behind", "mixed")
|
|
50
|
+
STALE_PINS = (*RAISABLE_PINS, "differs")
|
|
51
|
+
DEPENDABOT = ".github/dependabot.yml"
|
|
52
|
+
RENOVATE = (".github/renovate.json", ".github/workflows/renovate.yml")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class StampReport:
|
|
57
|
+
"""``status``: latest, behind, ahead, alpha, unknown, unreadable, or missing.
|
|
58
|
+
|
|
59
|
+
``omit`` is the stamp table's list of template files the repo leaves out on
|
|
60
|
+
purpose.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
version: str | None
|
|
64
|
+
table: str | None
|
|
65
|
+
status: str
|
|
66
|
+
latest: str | None
|
|
67
|
+
omit: tuple[str, ...] = ()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class FileReport:
|
|
72
|
+
"""``status``: current, matches, retired, one-off, partial, missing, omitted,
|
|
73
|
+
or unreadable.
|
|
74
|
+
|
|
75
|
+
``matches`` and ``retired`` name a release: the newest one the file equals.
|
|
76
|
+
``retired`` is a path the template no longer ships. ``partial`` is an ignore
|
|
77
|
+
file missing some of the template's rules, listed in ``detail``. ``omitted``
|
|
78
|
+
is a file the repo leaves out by design: declared in the stamp's ``omit``, or
|
|
79
|
+
the Dependabot or Renovate file of a repo that uses the other.
|
|
80
|
+
``pins`` is set for a pinned file: same, ahead, behind, mixed, or differs.
|
|
81
|
+
``waived`` lists an ignore file's template rules the repo leaves out because
|
|
82
|
+
it tracks paths they would ignore.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
path: str
|
|
86
|
+
kind: str
|
|
87
|
+
status: str
|
|
88
|
+
release: str | None = None
|
|
89
|
+
git: str | None = None
|
|
90
|
+
pins: str | None = None
|
|
91
|
+
detail: tuple[str, ...] = ()
|
|
92
|
+
waived: tuple[str, ...] = ()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def current(self) -> bool:
|
|
96
|
+
return self.status in ("current", "omitted") and self.pins not in STALE_PINS
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class RepoReport:
|
|
101
|
+
name: str
|
|
102
|
+
path: str
|
|
103
|
+
stamp: StampReport
|
|
104
|
+
files: list[FileReport] = field(default_factory=list)
|
|
105
|
+
settings: dict[str, str] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def current(self) -> bool:
|
|
109
|
+
return self.stamp.status == "latest" and all(f.current for f in self.files)
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, object]:
|
|
112
|
+
out = asdict(self)
|
|
113
|
+
out["current"] = self.current
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def latest_release(manifest: Manifest, installed: str) -> str | None:
|
|
118
|
+
"""The newest stable release this install knows: its own, or a tagged one."""
|
|
119
|
+
candidates = list(manifest.releases)
|
|
120
|
+
if not is_prerelease(installed):
|
|
121
|
+
candidates.append(installed)
|
|
122
|
+
keyed = [
|
|
123
|
+
(key, v) for v in candidates if (key := sort_key(v)) and not is_prerelease(v)
|
|
124
|
+
]
|
|
125
|
+
return max(keyed)[1] if keyed else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_stamp(repo: Path, latest: str | None) -> StampReport:
|
|
129
|
+
pyproject = repo / "pyproject.toml"
|
|
130
|
+
if not pyproject.is_file():
|
|
131
|
+
return StampReport(None, None, "missing", latest)
|
|
132
|
+
try:
|
|
133
|
+
data = tomllib.loads(pyproject.read_text())
|
|
134
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
135
|
+
return StampReport(None, None, "unreadable", latest)
|
|
136
|
+
tool = data.get("tool", {})
|
|
137
|
+
if not isinstance(tool, dict):
|
|
138
|
+
return StampReport(None, None, "unreadable", latest)
|
|
139
|
+
for table in STAMP_TABLES:
|
|
140
|
+
if table not in tool:
|
|
141
|
+
continue
|
|
142
|
+
# A stamp table with no string version is malformed, not missing
|
|
143
|
+
section = tool[table]
|
|
144
|
+
value = section.get("version") if isinstance(section, dict) else None
|
|
145
|
+
if not isinstance(value, str):
|
|
146
|
+
return StampReport(None, f"tool.{table}", "unreadable", latest)
|
|
147
|
+
omit = section.get("omit", [])
|
|
148
|
+
if not isinstance(omit, list) or not all(isinstance(o, str) for o in omit):
|
|
149
|
+
return StampReport(value, f"tool.{table}", "unreadable", latest)
|
|
150
|
+
status = _stamp_status(value, latest)
|
|
151
|
+
return StampReport(value, f"tool.{table}", status, latest, tuple(omit))
|
|
152
|
+
return StampReport(None, None, "missing", latest)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _stamp_status(value: str, latest: str | None) -> str:
|
|
156
|
+
key = sort_key(value)
|
|
157
|
+
if key is None:
|
|
158
|
+
return "unknown"
|
|
159
|
+
if is_prerelease(value):
|
|
160
|
+
return "alpha"
|
|
161
|
+
latest_key = sort_key(latest) if latest else None
|
|
162
|
+
if latest_key is None or key == latest_key:
|
|
163
|
+
return "latest"
|
|
164
|
+
return "behind" if key < latest_key else "ahead"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _git(repo: Path, *args: str) -> int:
|
|
168
|
+
try:
|
|
169
|
+
return subprocess.run(["git", *args], cwd=repo, capture_output=True).returncode
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
# No git on PATH: every query fails, so git_state reports "no git"
|
|
172
|
+
return 127
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def git_state(repo: Path, path: str) -> str:
|
|
176
|
+
"""tracked, ignored, untracked, absent, or no git."""
|
|
177
|
+
if not (repo / path).exists():
|
|
178
|
+
return "absent"
|
|
179
|
+
if _git(repo, "rev-parse", "--git-dir") != 0:
|
|
180
|
+
return "no git"
|
|
181
|
+
if _git(repo, "ls-files", "--error-unmatch", "--", path) == 0:
|
|
182
|
+
return "tracked"
|
|
183
|
+
if _git(repo, "check-ignore", "-q", "--", path) == 0:
|
|
184
|
+
return "ignored"
|
|
185
|
+
return "untracked"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _kind(key: str) -> str:
|
|
189
|
+
if key == SETTINGS_HOOKS:
|
|
190
|
+
return "hook-entries"
|
|
191
|
+
if key in PINNED:
|
|
192
|
+
return "pinned"
|
|
193
|
+
if key in CONFIG:
|
|
194
|
+
return "config"
|
|
195
|
+
if key in IGNORE:
|
|
196
|
+
return "ignore"
|
|
197
|
+
return "hook-script"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _repo_digest(repo: Path, key: str) -> tuple[str | None, bool]:
|
|
201
|
+
"""The repo's digest for ``key``, and whether the file could be read."""
|
|
202
|
+
try:
|
|
203
|
+
raw = DirSource(repo).read(file_path(key))
|
|
204
|
+
except OSError:
|
|
205
|
+
return None, False
|
|
206
|
+
if raw is None:
|
|
207
|
+
return None, True
|
|
208
|
+
try:
|
|
209
|
+
return file_digest(key, raw), True
|
|
210
|
+
except ValueError:
|
|
211
|
+
return None, False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _ordered(keys: set[str]) -> list[str]:
|
|
215
|
+
return sorted(keys, key=lambda k: (KINDS.index(_kind(k)), k))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def omitted(repo: Path, declared: tuple[str, ...]) -> set[str]:
|
|
219
|
+
"""Paths the repo leaves out by design: declared, or the other updater's files.
|
|
220
|
+
|
|
221
|
+
A repo on Renovate has no use for the Dependabot file, and one on Dependabot
|
|
222
|
+
none for the Renovate pair, so the file present names the choice.
|
|
223
|
+
"""
|
|
224
|
+
out = set(declared)
|
|
225
|
+
if (repo / DEPENDABOT).is_file():
|
|
226
|
+
out.update(RENOVATE)
|
|
227
|
+
elif any((repo / path).is_file() for path in RENOVATE):
|
|
228
|
+
out.add(DEPENDABOT)
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pins(repo: Path, template: Path, key: str) -> str | None:
|
|
233
|
+
if key not in PINNED:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
ours = (repo / key).read_text()
|
|
237
|
+
theirs = (template / key).read_text()
|
|
238
|
+
except (OSError, UnicodeDecodeError):
|
|
239
|
+
return None
|
|
240
|
+
return compare_pins(ours, theirs)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _ignore_report(repo: Path, template: Path, path: str, skip: set[str]) -> FileReport:
|
|
244
|
+
git = git_state(repo, path)
|
|
245
|
+
present = (repo / path).is_file()
|
|
246
|
+
if not present and path in skip:
|
|
247
|
+
return FileReport(path, "ignore", "omitted", git=git)
|
|
248
|
+
# git_state asked already when the file is there
|
|
249
|
+
if git == "no git" or (not present and _git(repo, "rev-parse", "--git-dir")):
|
|
250
|
+
# The rules are checked by asking git, so a directory that is not a repo
|
|
251
|
+
# cannot answer
|
|
252
|
+
return FileReport(path, "ignore", "unreadable", git="no git")
|
|
253
|
+
text = (template / path).read_text()
|
|
254
|
+
waived: list[str] = []
|
|
255
|
+
try:
|
|
256
|
+
if path == GITIGNORE:
|
|
257
|
+
gaps, waived = ignore_gaps(repo, text)
|
|
258
|
+
else:
|
|
259
|
+
gaps = attribute_gaps(repo, text)
|
|
260
|
+
except OSError:
|
|
261
|
+
return FileReport(path, "ignore", "unreadable", git=git)
|
|
262
|
+
status = "current" if not gaps else "partial" if present else "missing"
|
|
263
|
+
return FileReport(
|
|
264
|
+
path, "ignore", status, git=git, detail=tuple(gaps), waived=tuple(waived)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def check_repo(
|
|
269
|
+
repo: Path, template: Path, manifest: Manifest, installed: str
|
|
270
|
+
) -> RepoReport:
|
|
271
|
+
repo = repo.resolve()
|
|
272
|
+
shipped = fingerprint(DirSource(template))
|
|
273
|
+
latest = latest_release(manifest, installed)
|
|
274
|
+
stamp = read_stamp(repo, latest)
|
|
275
|
+
skip = omitted(repo, stamp.omit)
|
|
276
|
+
files: list[FileReport] = []
|
|
277
|
+
for key in _ordered(set(shipped) | set(manifest.files)):
|
|
278
|
+
value, readable = _repo_digest(repo, key)
|
|
279
|
+
path, kind = file_path(key), _kind(key)
|
|
280
|
+
git = git_state(repo, path)
|
|
281
|
+
if not readable:
|
|
282
|
+
files.append(FileReport(key, kind, "unreadable", git=git))
|
|
283
|
+
elif value is None:
|
|
284
|
+
if key in shipped:
|
|
285
|
+
status = "omitted" if path in skip or key in skip else "missing"
|
|
286
|
+
files.append(FileReport(key, kind, status, git=git))
|
|
287
|
+
elif value == shipped.get(key):
|
|
288
|
+
pins = _pins(repo, template, key)
|
|
289
|
+
files.append(FileReport(key, kind, "current", git=git, pins=pins))
|
|
290
|
+
elif (release := manifest.match(key, value)) is not None:
|
|
291
|
+
status = "matches" if key in shipped else "retired"
|
|
292
|
+
pins = _pins(repo, template, key) if key in shipped else None
|
|
293
|
+
files.append(FileReport(key, kind, status, release, git, pins))
|
|
294
|
+
else:
|
|
295
|
+
files.append(FileReport(key, kind, "one-off", git=git))
|
|
296
|
+
for path in IGNORE:
|
|
297
|
+
if (template / path).is_file():
|
|
298
|
+
files.append(_ignore_report(repo, template, path, skip))
|
|
299
|
+
settings = {path: git_state(repo, path) for path in CLAUDE_SETTINGS}
|
|
300
|
+
return RepoReport(repo.name, str(repo), stamp, files, settings)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def is_repo(path: Path) -> bool:
|
|
304
|
+
return path.is_dir() and (path / ".git").exists()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def check_fleet(
|
|
308
|
+
repos_dir: Path,
|
|
309
|
+
template: Path,
|
|
310
|
+
manifest: Manifest,
|
|
311
|
+
installed: str,
|
|
312
|
+
*,
|
|
313
|
+
include_unstamped: bool = False,
|
|
314
|
+
include_archived: bool = False,
|
|
315
|
+
) -> list[RepoReport]:
|
|
316
|
+
"""Check every clone directly under ``repos_dir``.
|
|
317
|
+
|
|
318
|
+
Skips ``*-archive`` repos and repos with no stamp unless asked for them.
|
|
319
|
+
"""
|
|
320
|
+
reports: list[RepoReport] = []
|
|
321
|
+
for child in sorted(repos_dir.iterdir()):
|
|
322
|
+
if not is_repo(child):
|
|
323
|
+
continue
|
|
324
|
+
if child.name.endswith("-archive") and not include_archived:
|
|
325
|
+
continue
|
|
326
|
+
report = check_repo(child, template, manifest, installed)
|
|
327
|
+
if report.stamp.status == "missing" and not include_unstamped:
|
|
328
|
+
continue
|
|
329
|
+
reports.append(report)
|
|
330
|
+
return reports
|
repotemplates/classes.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Comparison rules for the template-owned files beyond the Claude hooks.
|
|
2
|
+
|
|
3
|
+
Each class has its own idea of "the same file":
|
|
4
|
+
|
|
5
|
+
- **Pinned** files (the pre-commit config and the workflows) compare with their
|
|
6
|
+
pins masked: ``rev:`` values, action refs and their version comments, and a
|
|
7
|
+
``python-version`` matrix list, which follows each repo's ``requires-python``.
|
|
8
|
+
The pins are compared on their own, by name, and a repo whose pin is newer
|
|
9
|
+
than the template's is ahead, not drifted: Dependabot moves consumers first.
|
|
10
|
+
- **Config** files (Dependabot, Renovate) compare by exact content.
|
|
11
|
+
- **Ignore** files (``.gitignore``, ``.gitattributes``) compare by behaviour,
|
|
12
|
+
with local additions allowed: each template rule must hold in the repo, which
|
|
13
|
+
git itself answers for a probe path built from the rule. The fleet spells one
|
|
14
|
+
rule several ways (``.claude``, ``.claude/``, ``.claude/*``), so line text is
|
|
15
|
+
the wrong test.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CONFIG",
|
|
28
|
+
"GITATTRIBUTES",
|
|
29
|
+
"GITIGNORE",
|
|
30
|
+
"IGNORE",
|
|
31
|
+
"PINNED",
|
|
32
|
+
"Pin",
|
|
33
|
+
"attribute_gaps",
|
|
34
|
+
"compare_pins",
|
|
35
|
+
"ignore_gaps",
|
|
36
|
+
"mask",
|
|
37
|
+
"merge_pinned",
|
|
38
|
+
"pins",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
PINNED = (
|
|
42
|
+
".pre-commit-config.yaml",
|
|
43
|
+
".github/workflows/test.yml",
|
|
44
|
+
".github/workflows/publish.yml",
|
|
45
|
+
".github/workflows/renovate.yml",
|
|
46
|
+
)
|
|
47
|
+
CONFIG = (".github/dependabot.yml", ".github/renovate.json")
|
|
48
|
+
GITIGNORE = ".gitignore"
|
|
49
|
+
GITATTRIBUTES = ".gitattributes"
|
|
50
|
+
IGNORE = (GITIGNORE, GITATTRIBUTES)
|
|
51
|
+
|
|
52
|
+
_REPO = re.compile(r"^\s*-?\s*repo:\s*['\"]?(?P<name>[^'\"\s#]+)")
|
|
53
|
+
_REV = re.compile(
|
|
54
|
+
r"^(?P<head>\s*rev:\s*)(?P<q>['\"]?)(?P<ref>[^'\"\s#]+)(?P=q)(?P<tail>\s*#.*)?$"
|
|
55
|
+
)
|
|
56
|
+
_USES = re.compile(
|
|
57
|
+
r"^(?P<head>\s*(?:-\s*)?uses:\s*)(?P<q>['\"]?)(?P<name>[^@'\"\s]+)@"
|
|
58
|
+
r"(?P<ref>[^'\"\s#]+)(?P=q)(?P<tail>\s*#\s*(?P<comment>\S+).*)?$"
|
|
59
|
+
)
|
|
60
|
+
_MATRIX = re.compile(r"^(?P<head>\s*python-version:\s*)\[.*\]\s*$")
|
|
61
|
+
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)*)$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Pin:
|
|
66
|
+
"""One pinned dependency: a hook repo or an action, and the release it names.
|
|
67
|
+
|
|
68
|
+
``version`` is the action's version comment when it has one (the ref is then
|
|
69
|
+
a commit SHA), else the ref itself.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
version: str
|
|
74
|
+
line: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _lines(text: str) -> list[str]:
|
|
78
|
+
return text.split("\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mask(text: str) -> str:
|
|
82
|
+
"""The file with every pin and the Python matrix list replaced by a marker."""
|
|
83
|
+
out: list[str] = []
|
|
84
|
+
for line in _lines(text):
|
|
85
|
+
if m := _REV.match(line):
|
|
86
|
+
line = f"{m['head']}<pin>"
|
|
87
|
+
elif m := _USES.match(line):
|
|
88
|
+
line = f"{m['head']}{m['name']}@<pin>"
|
|
89
|
+
elif m := _MATRIX.match(line):
|
|
90
|
+
line = f"{m['head']}<matrix>"
|
|
91
|
+
out.append(line)
|
|
92
|
+
return "\n".join(out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pins(text: str) -> list[Pin]:
|
|
96
|
+
found: list[Pin] = []
|
|
97
|
+
repo: str | None = None
|
|
98
|
+
for number, line in enumerate(_lines(text)):
|
|
99
|
+
if m := _REPO.match(line):
|
|
100
|
+
repo = m["name"]
|
|
101
|
+
if (m := _REV.match(line)) and repo is not None:
|
|
102
|
+
found.append(Pin(repo, m["ref"], number))
|
|
103
|
+
elif m := _USES.match(line):
|
|
104
|
+
# A SHA ref names its release in the comment; any other comment
|
|
105
|
+
# ("# keep") says nothing about the version
|
|
106
|
+
comment = m["comment"]
|
|
107
|
+
version = comment if comment and _key(comment) else m["ref"]
|
|
108
|
+
found.append(Pin(m["name"], version, number))
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _key(version: str) -> tuple[int, ...] | None:
|
|
113
|
+
m = _VERSION.match(version)
|
|
114
|
+
return tuple(int(p) for p in m.group(1).split(".")) if m else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmp(ours: str, theirs: str) -> int | None:
|
|
118
|
+
"""-1, 0, or 1 for ours against theirs; None when either is not a version."""
|
|
119
|
+
if ours == theirs:
|
|
120
|
+
return 0
|
|
121
|
+
a, b = _key(ours), _key(theirs)
|
|
122
|
+
if a is None or b is None:
|
|
123
|
+
return None
|
|
124
|
+
width = max(len(a), len(b))
|
|
125
|
+
a, b = a + (0,) * (width - len(a)), b + (0,) * (width - len(b))
|
|
126
|
+
return (a > b) - (a < b)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compare_pins(repo_text: str, template_text: str) -> str:
|
|
130
|
+
"""same, ahead, behind, mixed, or differs: the repo's pins against the template's.
|
|
131
|
+
|
|
132
|
+
Only names both files pin are compared. ``differs`` is a pin that is not a
|
|
133
|
+
version on one side (a bare SHA) and is not equal.
|
|
134
|
+
"""
|
|
135
|
+
template = {p.name: p.version for p in pins(template_text)}
|
|
136
|
+
seen: set[int] = set()
|
|
137
|
+
for pin in pins(repo_text):
|
|
138
|
+
if pin.name not in template:
|
|
139
|
+
continue
|
|
140
|
+
result = _cmp(pin.version, template[pin.name])
|
|
141
|
+
if result is None:
|
|
142
|
+
return "differs"
|
|
143
|
+
seen.add(result)
|
|
144
|
+
if seen <= {0}:
|
|
145
|
+
return "same"
|
|
146
|
+
if seen <= {0, 1}:
|
|
147
|
+
return "ahead"
|
|
148
|
+
if seen <= {0, -1}:
|
|
149
|
+
return "behind"
|
|
150
|
+
return "mixed"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def merge_pinned(template_text: str, repo_text: str) -> str:
|
|
154
|
+
"""The template's file, keeping what the repo legitimately holds of its own.
|
|
155
|
+
|
|
156
|
+
A pin the repo has newer than the template's stays, and so does the repo's
|
|
157
|
+
Python matrix list when both files have exactly one. Everything else is the
|
|
158
|
+
template's.
|
|
159
|
+
"""
|
|
160
|
+
repo_pins: dict[str, tuple[str, str]] = {}
|
|
161
|
+
repo_lines = _lines(repo_text)
|
|
162
|
+
for pin in pins(repo_text):
|
|
163
|
+
repo_pins.setdefault(pin.name, (pin.version, repo_lines[pin.line]))
|
|
164
|
+
repo_matrix = [line for line in repo_lines if _MATRIX.match(line)]
|
|
165
|
+
template_lines = _lines(template_text)
|
|
166
|
+
template_matrix = [line for line in template_lines if _MATRIX.match(line)]
|
|
167
|
+
by_line = {p.line: p for p in pins(template_text)}
|
|
168
|
+
out: list[str] = []
|
|
169
|
+
for number, line in enumerate(template_lines):
|
|
170
|
+
pin = by_line.get(number)
|
|
171
|
+
if pin is not None and pin.name in repo_pins:
|
|
172
|
+
version, repo_line = repo_pins[pin.name]
|
|
173
|
+
if _cmp(version, pin.version) == 1:
|
|
174
|
+
line = _same_indent(line, repo_line)
|
|
175
|
+
elif _MATRIX.match(line) and len(repo_matrix) == len(template_matrix) == 1:
|
|
176
|
+
line = _same_indent(line, repo_matrix[0])
|
|
177
|
+
out.append(line)
|
|
178
|
+
return "\n".join(out)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _same_indent(template_line: str, repo_line: str) -> str:
|
|
182
|
+
"""The repo's line, re-indented to sit where the template's does."""
|
|
183
|
+
indent = template_line[: len(template_line) - len(template_line.lstrip())]
|
|
184
|
+
return indent + repo_line.lstrip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- ignore files -----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
_PROBE = "rtprobe"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _rules(text: str) -> list[str]:
|
|
193
|
+
return [
|
|
194
|
+
line.strip()
|
|
195
|
+
for line in text.splitlines()
|
|
196
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _probe(pattern: str) -> str:
|
|
201
|
+
"""A path the pattern matches: globs filled in, a directory given a child."""
|
|
202
|
+
path = pattern.lstrip("/").replace("**", "a").replace("*", _PROBE)
|
|
203
|
+
path = path.replace("?", "x")
|
|
204
|
+
return f"{path}{_PROBE}" if path.endswith("/") else path
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _git(
|
|
208
|
+
repo: Path, *args: str, stdin: str | None = None
|
|
209
|
+
) -> subprocess.CompletedProcess[str]:
|
|
210
|
+
# The user's own excludes and attributes files would answer for the repo
|
|
211
|
+
config = [
|
|
212
|
+
"-c",
|
|
213
|
+
"core.excludesFile=/dev/null",
|
|
214
|
+
"-c",
|
|
215
|
+
"core.attributesFile=/dev/null",
|
|
216
|
+
]
|
|
217
|
+
return subprocess.run(
|
|
218
|
+
["git", *config, *args], cwd=repo, input=stdin, capture_output=True, text=True
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def ignore_gaps(repo: Path, template_text: str) -> tuple[list[str], list[str]]:
|
|
223
|
+
"""The template's ignore rules that do not hold in the repo, and those waived.
|
|
224
|
+
|
|
225
|
+
A rule holds when git ignores its probe path (or, for a ``!`` rule, does not).
|
|
226
|
+
``--no-index`` asks about the rules alone, so a tracked file does not answer.
|
|
227
|
+
A rule that does not hold is waived when the repo tracks a path it would
|
|
228
|
+
ignore: tracking ``.claude/settings.json`` is a choice not to ignore
|
|
229
|
+
``.claude/``, which is a per-repo decision, not drift.
|
|
230
|
+
"""
|
|
231
|
+
rules = _rules(template_text)
|
|
232
|
+
probes = [_probe(r.removeprefix("!")) for r in rules]
|
|
233
|
+
ignored = _ignored(repo, probes)
|
|
234
|
+
gaps: list[str] = []
|
|
235
|
+
waived: list[str] = []
|
|
236
|
+
for i, (rule, probe) in enumerate(zip(rules, probes, strict=True)):
|
|
237
|
+
if (probe in ignored) != rule.startswith("!"):
|
|
238
|
+
continue
|
|
239
|
+
(waived if _tracks(repo, rules, i) else gaps).append(rule)
|
|
240
|
+
return gaps, waived
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _tracks(repo: Path, rules: Sequence[str], i: int) -> bool:
|
|
244
|
+
"""Whether the repo tracks a path the template's rule ``rules[i]`` ignores.
|
|
245
|
+
|
|
246
|
+
git matches the rule as a gitignore pattern, together with the template's
|
|
247
|
+
later negations: ``.env.*`` does not claim a tracked ``.env.example`` while
|
|
248
|
+
the template's own ``!.env.example`` un-ignores it.
|
|
249
|
+
"""
|
|
250
|
+
if rules[i].startswith("!"):
|
|
251
|
+
return False
|
|
252
|
+
later = [r for r in rules[i + 1 :] if r.startswith("!")]
|
|
253
|
+
excludes = [f"--exclude={r}" for r in (rules[i], *later)]
|
|
254
|
+
result = _git(repo, "ls-files", "--cached", "--ignored", *excludes)
|
|
255
|
+
return result.returncode == 0 and bool(result.stdout.strip())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ignored(repo: Path, probes: Sequence[str]) -> set[str]:
|
|
259
|
+
stdin = "\n".join(probes) + "\n"
|
|
260
|
+
result = _git(repo, "check-ignore", "--no-index", "--stdin", stdin=stdin)
|
|
261
|
+
# 1 is "nothing ignored", not a failure
|
|
262
|
+
if result.returncode not in (0, 1):
|
|
263
|
+
raise OSError(result.stderr.strip() or "git check-ignore failed")
|
|
264
|
+
return set(result.stdout.splitlines())
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _expected(attr: str) -> tuple[str, str]:
|
|
268
|
+
"""An attribute spec as git check-attr reports it: (name, value)."""
|
|
269
|
+
if attr.startswith("-"):
|
|
270
|
+
return attr[1:], "unset"
|
|
271
|
+
if attr.startswith("!"):
|
|
272
|
+
return attr[1:], "unspecified"
|
|
273
|
+
name, sep, value = attr.partition("=")
|
|
274
|
+
return name, value if sep else "set"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def attribute_gaps(repo: Path, template_text: str) -> list[str]:
|
|
278
|
+
"""The template's attribute lines that do not hold in the repo."""
|
|
279
|
+
gaps: list[str] = []
|
|
280
|
+
for line in _rules(template_text):
|
|
281
|
+
pattern, *attrs = line.split()
|
|
282
|
+
probe = _probe(pattern)
|
|
283
|
+
for name, value in (_expected(a) for a in attrs):
|
|
284
|
+
result = _git(repo, "check-attr", name, "--", probe)
|
|
285
|
+
if result.returncode != 0:
|
|
286
|
+
raise OSError(result.stderr.strip() or "git check-attr failed")
|
|
287
|
+
got = result.stdout.strip().rsplit(": ", 1)[-1]
|
|
288
|
+
if got != value:
|
|
289
|
+
gaps.append(line)
|
|
290
|
+
break
|
|
291
|
+
return gaps
|