utils/repo_contract/hook_sync.py
Krzysztof kuhy Rudnicki d1ca1e3d5b
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
repo_contract: launcher gate (icon ink radius, compliant label, hidden debug variants) + hook_sync
Flutter repos now fail the repo contract when a launcher icon's ink is not on
the shared 340px circle (png_ink), the launcher label breaks the naming rule
(capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an
applicationIdSuffix variant still carries the LAUNCHER category. hook_sync
rewrites a repo's stale repo-contract `files:` line to the widened template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
2026-10-23 14:54:15 +02:00

75 lines
2.8 KiB
Python

"""Bring every repo's repo-contract hook `files:` line up to the template.
Usage: python3 -m repo_contract.hook_sync [--apply] [REPO ...]
(default REPOs: the sweep roots, git repos directly under ~/src and ~).
Why this exists: `install.py` only checks that the hook id is present
(`precommit.needs_hook`), so widening the template's `files:` regex (as for
the Flutter launcher rules, 2026-10-23) never reaches a repo installed
earlier. This rewrites that one line with `precommit.set_hook_line` and leaves
the rest of the config, comments included, untouched.
Dry run by default: exit 1 when any repo is stale, 0 when all are current.
With --apply, stale repos are rewritten (not committed) and it exits 0.
"""
from __future__ import annotations
import argparse
import sys
from collections import Counter
from pathlib import Path
if __package__ in (None, ""): # pragma: no cover - plain-script import path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from repo_contract.bootstrap import TEMPLATES
from repo_contract.precommit import hook_line, needs_hook, set_hook_line
from repo_contract.sweep import find_repos
HOOK_ID = "repo-contract"
SNIPPET = TEMPLATES / "pre-commit-snippet.yaml"
KEY = "files"
def template_line() -> str:
"""The template's `files:` line, exactly as a hook block carries it."""
lines = SNIPPET.read_text(encoding="utf-8").splitlines()
return next(ln for ln in lines if ln.startswith(f" {KEY}:"))
def status(repo: Path) -> str:
"""`no hook`, `current` or `stale` for one repo."""
if needs_hook(repo, HOOK_ID):
return "no hook"
current = hook_line(repo, HOOK_ID, KEY) == template_line()
return "current" if current else "stale"
def main(argv: list[str] | None = None, roots: list[Path] | None = None) -> int:
"""CLI entry point; see the module docstring for exit codes."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("repos", nargs="*", type=Path)
parser.add_argument("--apply", action="store_true", help="rewrite stale hooks")
args = parser.parse_args(argv)
home = Path.home()
repos = args.repos or find_repos(roots or [home / "src", home])
counts: Counter[str] = Counter()
for repo in repos:
state = status(repo)
if state == "stale" and args.apply:
set_hook_line(repo, HOOK_ID, KEY, template_line())
state = "updated"
counts[state] += 1
label = "would update" if state == "stale" else state
print(f"{label}: {repo}")
print(
f"hook-sync: {counts['stale']} stale, {counts['updated']} updated,"
f" {counts['current']} current, {counts['no hook']} without the hook"
)
return 1 if counts["stale"] else 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())