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.
Files changed (43) hide show
  1. repotemplates/__init__.py +1 -0
  2. repotemplates/__main__.py +3 -0
  3. repotemplates/check.py +330 -0
  4. repotemplates/classes.py +291 -0
  5. repotemplates/cli.py +265 -0
  6. repotemplates/data/manifest.json +346 -0
  7. repotemplates/host.py +27 -0
  8. repotemplates/manifest.py +219 -0
  9. repotemplates/paths.py +79 -0
  10. repotemplates/prompts/__init__.py +1 -0
  11. repotemplates/prompts/install-renovatabot/SKILL.md +48 -0
  12. repotemplates/prompts/install-template/SKILL.md +405 -0
  13. repotemplates/render.py +130 -0
  14. repotemplates/scripts/init.sh +373 -0
  15. repotemplates/scripts/renovatabot-enroll.sh +193 -0
  16. repotemplates/template/.claude/CLAUDE.md +42 -0
  17. repotemplates/template/.claude/hooks/lint-typecheck.sh +42 -0
  18. repotemplates/template/.claude/settings.json +15 -0
  19. repotemplates/template/.gitattributes +1 -0
  20. repotemplates/template/.github/dependabot.yml +25 -0
  21. repotemplates/template/.github/renovate.json +30 -0
  22. repotemplates/template/.github/workflows/publish.yml +34 -0
  23. repotemplates/template/.github/workflows/renovate.yml +47 -0
  24. repotemplates/template/.github/workflows/test.yml +29 -0
  25. repotemplates/template/.gitignore +22 -0
  26. repotemplates/template/.planners/README.md +4 -0
  27. repotemplates/template/.planners/plans/.gitkeep +0 -0
  28. repotemplates/template/.pre-commit-config.yaml +34 -0
  29. repotemplates/template/.python-version +1 -0
  30. repotemplates/template/CHANGELOG.md +20 -0
  31. repotemplates/template/MODULE__NAME/__init__.py +0 -0
  32. repotemplates/template/MODULE__NAME/cli.py +11 -0
  33. repotemplates/template/MODULE__NAME/py.typed +0 -0
  34. repotemplates/template/README.md +1 -0
  35. repotemplates/template/pyproject.toml +91 -0
  36. repotemplates/template/tests/__init__.py +0 -0
  37. repotemplates/template/tests/test_MODULE__NAME.py +13 -0
  38. repotemplates/upgrade.py +244 -0
  39. repotemplates/versions.py +27 -0
  40. repotemplates-0.13.0.dist-info/METADATA +271 -0
  41. repotemplates-0.13.0.dist-info/RECORD +43 -0
  42. repotemplates-0.13.0.dist-info/WHEEL +4 -0
  43. repotemplates-0.13.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1 @@
1
+ """Scaffold repos from repotemplates and check them against it."""
@@ -0,0 +1,3 @@
1
+ from repotemplates.cli import main
2
+
3
+ main()
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
@@ -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