Skip to content

Commit 1ece9d9

Browse files
committed
Table-drive the setup and lint rosters, split oversized modules
Each thing declared once, in the module that owns it: the setup guide's 11 steps and `lint-repo`'s 25 hand-numbered checks become registry tables, the three cache entry kinds share a base, `tool_runner_page` folds into the registry it renders, and the file-inventory family leaves `Metadata` for its own module. `lint_changelog_dates` and `manage_setup_guide` shrink to drivers over named phases, and `cli.py`'s commands are physically grouped by section. Behavior worth reviewing: `lint-repo` now runs the branch-ruleset and immutable-releases checks it defined but never invoked; `--repo` means the `owner/name` slug everywhere, with the upstream toolkit moving to `--upstream-repo`; `apply-labels` no longer infers `💖 sponsor` from the word "funding"; and every `gh` call runs the pinned, checksum-verified binary. The suite also stops shelling out to a real `gh`, which was making `tests/test_lint_repo.py` depend on the developer's own `gh auth` state.
1 parent 74619bf commit 1ece9d9

28 files changed

Lines changed: 5188 additions & 4573 deletions

.claude/agents/sphinx-docs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ Document **dataclass fields with attribute docstrings** (PEP 257 string literal
459459

460460
## Recipes for common doc artifacts
461461

462-
The patterns below are how this repo renders `docs/configuration.md`, `docs/cli.md`, and `docs/install.md`. Downstream CLI projects can replicate them verbatim by pointing at their own dataclass schema and Click root command. Every reference page renders live (click-extra directives for the CLI and config references, `{python:render}` blocks over `repomatic.tool_runner_page` for the tool registry); the repo has no `docs_update.py` regenerator, and only `install.md`'s matrix stays checked in as a marker region for GitHub rendering. Free-form per-tool prose lives in the registry itself (`ToolSpec.docs_notes`), not in the page, so it survives without marker islands.
462+
The patterns below are how this repo renders `docs/configuration.md`, `docs/cli.md`, and `docs/install.md`. Downstream CLI projects can replicate them verbatim by pointing at their own dataclass schema and Click root command. Every reference page renders live (click-extra directives for the CLI and config references, `{python:render}` blocks over `repomatic.tool_registry` for the tool registry); the repo has no `docs_update.py` regenerator, and only `install.md`'s matrix stays checked in as a marker region for GitHub rendering. Free-form per-tool prose lives in the registry itself (`ToolSpec.docs_notes`), not in the page, so it survives without marker islands.
463463

464464
### `configuration.md`: option reference from a dataclass
465465

changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
- `pr-sync` now clears a conversation lock standing in the way of retiring a stale automation PR, instead of dying on the refused close comment.
1818
- `sync-labels` now hands labelmaker the canonical token (`REPOMATIC_PAT`, then `GH_TOKEN`, then `GITHUB_TOKEN`), so an environment carrying only the PAT syncs authenticated.
1919
- Every re-lock (`sync-dep-sources`, `audit --fix`) now passes the project's own `[tool.uv] exclude-newer` explicitly, so CI's ambient `UV_EXCLUDE_NEWER` can no longer retime the lock.
20+
- **Breaking:** `repomatic init` and `repomatic workflow lint` take `--upstream-repo` for the upstream toolkit; `--repo` now means the `owner/name` slug everywhere, including on `sync-labels`.
21+
- `lint-repo` now runs the branch-ruleset and immutable-releases checks it already defined but never invoked.
22+
- `apply-labels` no longer applies `💖 sponsor` from the words "funding" or "sponsor", or from a pull request touching `.github/funding.yml`: only `sponsor-label` sets it, from actual sponsorship.
23+
- Every `gh` call now runs the registry-pinned, checksum-verified binary, falling back to `$PATH` with a warning when it cannot be installed.
2024

2125
## [`7.10.0` (2026-08-12)](https://github.com/kdeldycke/repomatic/compare/v7.9.0...v7.10.0)
2226

docs/repomatic.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@
164164
:undoc-members:
165165
```
166166

167+
## `repomatic.file_inventory` module
168+
169+
```{eval-rst}
170+
.. automodule:: repomatic.file_inventory
171+
:members:
172+
:show-inheritance:
173+
:undoc-members:
174+
```
175+
167176
## `repomatic.frontmatter` module
168177

169178
```{eval-rst}
@@ -371,15 +380,6 @@
371380
:undoc-members:
372381
```
373382

374-
## `repomatic.tool_runner_page` module
375-
376-
```{eval-rst}
377-
.. automodule:: repomatic.tool_runner_page
378-
:members:
379-
:show-inheritance:
380-
:undoc-members:
381-
```
382-
383383
## `repomatic.uv` module
384384

385385
```{eval-rst}

docs/tool-runner.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ invoke(repomatic, args=['run', '--list'])
4444
## Available tools
4545

4646
```{python:render}
47-
from repomatic.tool_runner_page import tool_summary
47+
from repomatic.tool_registry import tool_summary
4848
4949
print(tool_summary())
5050
```
@@ -248,7 +248,7 @@ For tools with subcommands (ruff, biome, gitleaks), the subcommand goes after `-
248248
## Tool details
249249

250250
```{python:render}
251-
from repomatic.tool_runner_page import tool_reference
251+
from repomatic.tool_registry import tool_reference
252252
253253
print(tool_reference())
254254
```

repomatic/cache.py

Lines changed: 155 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@
5050
import time
5151
from dataclasses import dataclass
5252
from pathlib import Path
53+
from typing import ClassVar
5354

5455
from extra_platforms import is_macos, is_windows
5556

5657
from .config import load_repomatic_config
58+
from .humanize import format_age, format_file_size
5759

5860
TYPE_CHECKING = False
5961
if TYPE_CHECKING:
@@ -95,69 +97,134 @@ def _atomic_write(dest: Path, prefix: str, write: Callable[[Path], object]) -> N
9597

9698

9799
@dataclass(frozen=True)
98-
class CacheEntry:
100+
class CachedFile:
101+
"""The filesystem facts every cached entry carries, whatever it holds.
102+
103+
The three caches (binaries, HTTP responses, tool configs) differ only in
104+
how they *name* an entry; everything the listing, the age filter and the
105+
purge loop need is here, so those all take a `CachedFile` and never care
106+
which subtree it came from.
107+
108+
Subclasses supply their own identity fields plus {attr}`kind` and
109+
{attr}`scope`.
110+
"""
111+
112+
size: int
113+
"""File size in bytes."""
114+
115+
path: Path
116+
"""Absolute path to the cached file."""
117+
118+
mtime: float
119+
"""File modification time (seconds since epoch)."""
120+
121+
kind: ClassVar[str] = ""
122+
"""The cache this entry belongs to, as the `repomatic cache show` table
123+
spells it."""
124+
125+
@property
126+
def scope(self) -> str:
127+
"""The name a `cache clean` filter matches this entry on.
128+
129+
Doubles as the table's subject column: the thing a reader identifies
130+
the entry by (`--tool ruff`, `--namespace pypi`) is the same thing the
131+
listing shows them, so one property serves both.
132+
"""
133+
raise NotImplementedError
134+
135+
@property
136+
def detail(self) -> str:
137+
"""What distinguishes this entry from its siblings in the same scope."""
138+
raise NotImplementedError
139+
140+
def is_fresh(self, max_age_days: int | None) -> bool:
141+
"""Whether this entry is younger than the age cutoff.
142+
143+
A `None` cutoff keeps nothing: age-unfiltered clears delete every
144+
entry the caller's other filters matched.
145+
"""
146+
if max_age_days is None:
147+
return False
148+
return self.mtime >= time.time() - max_age_days * 86400
149+
150+
def as_row(self) -> tuple[str, str, str, str, str]:
151+
"""Render this entry as one `repomatic cache show` table row."""
152+
return (
153+
self.kind,
154+
self.scope,
155+
self.detail,
156+
format_file_size(self.size),
157+
format_age(self.mtime),
158+
)
159+
160+
161+
@dataclass(frozen=True)
162+
class CacheEntry(CachedFile):
99163
"""A single cached binary with its metadata."""
100164

101-
tool: str
165+
tool: str = ""
102166
"""Tool name (registry key)."""
103167

104-
version: str
168+
version: str = ""
105169
"""Pinned version string."""
106170

107-
platform: str
171+
platform: str = ""
108172
"""Platform key (e.g., `linux-x64`, `macos-arm64`)."""
109173

110-
executable: str
174+
executable: str = ""
111175
"""Executable filename."""
112176

113-
size: int
114-
"""File size in bytes."""
177+
kind: ClassVar[str] = "binary"
115178

116-
path: Path
117-
"""Absolute path to the cached binary."""
179+
@property
180+
def scope(self) -> str:
181+
return self.tool
118182

119-
mtime: float
120-
"""File modification time (seconds since epoch)."""
183+
@property
184+
def detail(self) -> str:
185+
return f"{self.version} ({self.platform})"
121186

122187

123188
@dataclass(frozen=True)
124-
class HttpCacheEntry:
189+
class HttpCacheEntry(CachedFile):
125190
"""A single cached HTTP response with its metadata."""
126191

127-
namespace: str
192+
namespace: str = ""
128193
"""Cache namespace (e.g., `pypi`, `github-releases`)."""
129194

130-
key: str
195+
key: str = ""
131196
"""Cache key within the namespace (e.g., `requests`, `astral-sh/ruff`)."""
132197

133-
size: int
134-
"""File size in bytes."""
198+
kind: ClassVar[str] = "http"
135199

136-
path: Path
137-
"""Absolute path to the cached response file."""
200+
@property
201+
def scope(self) -> str:
202+
return self.namespace
138203

139-
mtime: float
140-
"""File modification time (seconds since epoch)."""
204+
@property
205+
def detail(self) -> str:
206+
return self.key
141207

142208

143209
@dataclass(frozen=True)
144-
class ConfigCacheEntry:
210+
class ConfigCacheEntry(CachedFile):
145211
"""A single cached tool configuration file with its metadata."""
146212

147-
tool: str
213+
tool: str = ""
148214
"""Tool name (registry key)."""
149215

150-
filename: str
216+
filename: str = ""
151217
"""Config filename (e.g., `yamllint.yaml`, `biome.json`)."""
152218

153-
size: int
154-
"""File size in bytes."""
219+
kind: ClassVar[str] = "config"
155220

156-
path: Path
157-
"""Absolute path to the cached config file."""
221+
@property
222+
def scope(self) -> str:
223+
return self.tool
158224

159-
mtime: float
160-
"""File modification time (seconds since epoch)."""
225+
@property
226+
def detail(self) -> str:
227+
return self.filename
161228

162229

163230
def _platform_cache_dir() -> Path:
@@ -369,16 +436,11 @@ def clear_cache(
369436
this many days. Otherwise remove all matching entries.
370437
:return: Tuple of (files_deleted, bytes_freed).
371438
"""
372-
bin_root = _bin_dir()
373-
if not bin_root.is_dir():
374-
return 0, 0
375-
376-
return _purge(
439+
return _clear_subtree(
377440
cache_info(),
378-
bin_root,
379-
keep=lambda entry: (
380-
(tool is not None and entry.tool != tool) or _is_fresh(entry, max_age_days)
381-
),
441+
_bin_dir(),
442+
scope=tool,
443+
max_age_days=max_age_days,
382444
sidecars=True,
383445
)
384446

@@ -496,17 +558,11 @@ def clear_http_cache(
496558
this many days. Otherwise remove all matching entries.
497559
:return: Tuple of (files_deleted, bytes_freed).
498560
"""
499-
http_root = _http_dir()
500-
if not http_root.is_dir():
501-
return 0, 0
502-
503-
return _purge(
561+
return _clear_subtree(
504562
http_cache_info(),
505-
http_root,
506-
keep=lambda entry: (
507-
(namespace is not None and entry.namespace != namespace)
508-
or _is_fresh(entry, max_age_days)
509-
),
563+
_http_dir(),
564+
scope=namespace,
565+
max_age_days=max_age_days,
510566
)
511567

512568

@@ -593,35 +649,66 @@ def clear_config_cache(
593649
days, matching {func}`clear_cache` and {func}`clear_http_cache`.
594650
:return: Tuple of (files_deleted, bytes_freed).
595651
"""
596-
config_root = _config_dir()
597-
if not config_root.is_dir():
598-
return 0, 0
599-
600-
return _purge(
652+
return _clear_subtree(
601653
config_cache_info(),
602-
config_root,
603-
keep=lambda entry: (
604-
(tool is not None and entry.tool != tool) or _is_fresh(entry, max_age_days)
605-
),
654+
_config_dir(),
655+
scope=tool,
656+
max_age_days=max_age_days,
606657
)
607658

608659

609-
def _is_fresh(
610-
entry: CacheEntry | HttpCacheEntry | ConfigCacheEntry,
611-
max_age_days: int | None,
612-
) -> bool:
613-
"""Whether *entry* is younger than the age cutoff.
660+
def cache_rows() -> tuple[list[tuple[str, str, str, str, str]], int]:
661+
"""List every cached file across the three caches, as table rows.
662+
663+
Backs `repomatic cache show`: each entry renders itself
664+
({meth}`CachedFile.as_row`), so the command stays a print call and a new
665+
cache kind shows up in the listing by existing.
614666
615-
A `None` cutoff keeps nothing: age-unfiltered clears delete every entry
616-
the caller's other filters matched.
667+
:return: `(rows, total_size)`, rows ordered binaries, then HTTP responses,
668+
then tool configs.
617669
"""
618-
if max_age_days is None:
619-
return False
620-
return entry.mtime >= time.time() - max_age_days * 86400
670+
entries: list[Any] = [*cache_info(), *http_cache_info(), *config_cache_info()]
671+
return [entry.as_row() for entry in entries], sum(e.size for e in entries)
672+
673+
674+
def _clear_subtree(
675+
entries: list[Any],
676+
root: Path,
677+
*,
678+
scope: str | None,
679+
max_age_days: int | None,
680+
sidecars: bool = False,
681+
) -> tuple[int, int]:
682+
"""Remove the entries of one cache subtree, honoring both filters.
683+
684+
The shared body of {func}`clear_cache`, {func}`clear_http_cache` and
685+
{func}`clear_config_cache`, which differ only in the subtree they walk and
686+
the name their scope filter goes by on the command line.
687+
688+
:param entries: Candidate entries, from that cache's `*_cache_info()`.
689+
:param root: The subtree they live in. A missing one clears nothing.
690+
:param scope: Keep entries whose {attr}`~CachedFile.scope` differs. `None`
691+
selects every entry.
692+
:param max_age_days: Keep entries younger than this. `None` selects every
693+
entry.
694+
:param sidecars: Also remove each entry's `.sha256` sidecar.
695+
:return: Tuple of (files_deleted, bytes_freed).
696+
"""
697+
if not root.is_dir():
698+
return 0, 0
699+
return _purge(
700+
entries,
701+
root,
702+
keep=lambda entry: (
703+
(scope is not None and entry.scope != scope)
704+
or entry.is_fresh(max_age_days)
705+
),
706+
sidecars=sidecars,
707+
)
621708

622709

623710
def _purge(
624-
entries: list[CacheEntry] | list[HttpCacheEntry] | list[ConfigCacheEntry],
711+
entries: list[Any],
625712
root: Path,
626713
*,
627714
keep: Callable[[Any], bool],

0 commit comments

Comments
 (0)