From 6d96c58f0117a47311ffa63084beb71093ece502 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 12:46:04 +0200 Subject: [PATCH] repo_contract: idempotent installer for new repos teatr-pw and teatr-pw-pytania (created ad hoc 2026-10-09) had no shim, hook or '## Commands' section: the gate was only ever rolled out by hand. The installer writes shim + pre-commit hook + CI workflow and bootstraps the Commands section and scripts/test_changed.sh, exiting 1 while remains. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TjyHmRrjZssaxXDWcGuCeQ --- repo_contract/README.md | 17 ++- repo_contract/install.py | 169 ++++++++++++++++++++++++++ repo_contract/tests/test_install.py | 114 +++++++++++++++++ scripts/install_repo_contract_gate.sh | 29 +++++ 4 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 repo_contract/install.py create mode 100644 repo_contract/tests/test_install.py create mode 100755 scripts/install_repo_contract_gate.sh diff --git a/repo_contract/README.md b/repo_contract/README.md index 0d7959f..cd1f963 100644 --- a/repo_contract/README.md +++ b/repo_contract/README.md @@ -54,10 +54,19 @@ is written as ``, which the gate rejects until a human fills it. ## Adding the gate to a repo -1. Copy `templates/check_repo_contract.shim.sh` to `scripts/check_repo_contract.sh` - (never copy `scripts/check_repo_contract.sh` from this repo). -2. Add `templates/pre-commit-snippet.yaml` to `.pre-commit-config.yaml`. -3. Copy `templates/repo-contract.yml` to `.github/workflows/`. +Run this on every new repo right after `git init` (idempotent): + +```bash +~/src/utils/scripts/install_repo_contract_gate.sh # --check to preview +``` + +It writes the `scripts/check_repo_contract.sh` shim, the pre-commit hook, the +CI workflow (skipped when the repo has `.dep-freshness-no-workflow`, i.e. no +GitHub Actions), then bootstraps the `## Commands` section and +`scripts/test_changed.sh`. It exits 1 while a `` remains. Never copy +`scripts/check_repo_contract.sh` from this repo (it is the real gate, not a shim). +The hook only fires on commits touching CLAUDE.md/AGENTS.md/test_changed.sh, +so a repo that never got the installer is caught only by `sweep.py`. ## Tests diff --git a/repo_contract/install.py b/repo_contract/install.py new file mode 100644 index 0000000..dc230ae --- /dev/null +++ b/repo_contract/install.py @@ -0,0 +1,169 @@ +"""Install the repo-contract gate into a repo and bootstrap its Commands section. + +Usage: python3 -m repo_contract.install REPO [--check] [--no-bootstrap] +(or `scripts/install_repo_contract_gate.sh REPO`). + +Why this exists: the gate used to be four manual steps in the README. Nothing +ran them for a repo created after the 2026-10-04 rollout (teatr-pw and +teatr-pw-pytania were made ad hoc on 2026-10-09 and came out with no +`## Commands`, no shim, no hook), so the sweep was the first thing to notice. +One idempotent command now does all of it: delegate shim, pre-commit hook, +CI workflow (unless the repo opted out of GitHub Actions) and the Commands +section plus `scripts/test_changed.sh` through bootstrap. +""" + +from __future__ import annotations + +import argparse +import sys +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 dep_freshness.install import NO_WORKFLOW_MARKER +from repo_contract import bootstrap +from repo_contract.check import violations +from repo_contract.infer import propose +from repo_contract.stacks import detect_stacks + +TEMPLATES = bootstrap.TEMPLATES +SHIM = Path("scripts/check_repo_contract.sh") +WORKFLOW = Path(".github/workflows/repo-contract.yml") +PRECOMMIT = Path(".pre-commit-config.yaml") +HOOK_ID = "repo-contract" +LOCAL_REPO = " - repo: local" +EMPTY_PRECOMMIT = f"repos:\n{LOCAL_REPO}\n hooks:\n" + + +def _template(name: str) -> str: + return (TEMPLATES / name).read_text(encoding="utf-8") + + +def _needs_shim(repo: Path) -> bool: + """False for utils itself, where SHIM is the real checker, not a delegate.""" + if (repo / "repo_contract" / "check.py").is_file(): + return False + target = repo / SHIM + return not target.is_file() or target.read_text(encoding="utf-8") != _template( + "check_repo_contract.shim.sh" + ) + + +def _needs_workflow(repo: Path) -> bool: + """Missing or drifted CI workflow, unless the repo opted out of Actions.""" + if (repo / NO_WORKFLOW_MARKER).exists(): + return False + target = repo / WORKFLOW + return not target.is_file() or target.read_text(encoding="utf-8") != _template( + "repo-contract.yml" + ) + + +def _needs_hook(repo: Path) -> bool: + target = repo / PRECOMMIT + return not target.is_file() or f"id: {HOOK_ID}" not in target.read_text( + encoding="utf-8" + ) + + +def _local_block_end(lines: list[str]) -> int: + """Index just past the LAST `- repo: local` block, or -1 if there is none.""" + starts = [n for n, line in enumerate(lines) if line.rstrip() == LOCAL_REPO] + if not starts: + return -1 + after = [ + n for n in range(starts[-1] + 1, len(lines)) if lines[n].startswith(" - repo:") + ] + return after[0] if after else len(lines) + + +def _write_hook(repo: Path) -> None: + """Insert the hook into the LAST local hooks list, keeping comments intact.""" + target = repo / PRECOMMIT + body = target.read_text(encoding="utf-8") if target.is_file() else EMPTY_PRECOMMIT + lines = (body if body.endswith("\n") else body + "\n").split("\n")[:-1] + cut = _local_block_end(lines) + if cut < 0: + raise ValueError( + f"{target} has no `{LOCAL_REPO}` block to add the hook to; " + "add one by hand, then re-run" + ) + block = [ + ln + for ln in _template("pre-commit-snippet.yaml").rstrip("\n").split("\n") + if not ln.startswith("#") + ] + lines[cut:cut] = ["", *block] + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def plan(repo: Path) -> list[str]: + """Which gate pieces are missing or drifted (the Commands section excluded).""" + todo = [] + if _needs_shim(repo): + todo.append(str(SHIM)) + if _needs_workflow(repo): + todo.append(str(WORKFLOW)) + if _needs_hook(repo): + todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + return todo + + +def install(repo: Path) -> list[str]: + """Write every missing or drifted gate piece. Returns what changed.""" + done = [] + if _needs_shim(repo): + target = repo / SHIM + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(_template("check_repo_contract.shim.sh"), encoding="utf-8") + target.chmod(0o755) + done.append(str(SHIM)) + if _needs_workflow(repo): + target = repo / WORKFLOW + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(_template("repo-contract.yml"), encoding="utf-8") + done.append(str(WORKFLOW)) + if _needs_hook(repo): + _write_hook(repo) + done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + return done + + +def bootstrap_commands(repo: Path) -> list[str]: + """Write the missing Commands lines and test_changed.sh (never overwrites).""" + prop = propose(repo, detect_stacks(repo)) + doc = bootstrap.doc_path(repo) + text = doc.read_text(encoding="utf-8") if doc.is_file() else "" + new = bootstrap.missing_lines(prop, text) + return bootstrap.apply(repo, doc, text, new, bootstrap.script_plan(repo, prop)) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point; 0 contract satisfied, 1 violations left, 2 not a git repo.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", type=Path) + parser.add_argument("--check", action="store_true", help="report, change nothing") + parser.add_argument("--no-bootstrap", action="store_true", help="gate pieces only") + args = parser.parse_args(argv) + repo = args.repo.expanduser().resolve() + if not (repo / ".git").exists(): + print(f"install: {repo} is not a git repo", file=sys.stderr) + return 2 + if args.check: + for piece in plan(repo): + print(f"would write: {piece}") + else: + for piece in install(repo): + print(f"wrote: {piece}") + if not args.no_bootstrap: + for action in bootstrap_commands(repo): + print(f"bootstrap: {action}") + found, _ = violations(repo) + for problem in found: + print(f"repo-contract: {repo.name}: {problem}", file=sys.stderr) + return 1 if found else 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/repo_contract/tests/test_install.py b/repo_contract/tests/test_install.py new file mode 100644 index 0000000..6514e14 --- /dev/null +++ b/repo_contract/tests/test_install.py @@ -0,0 +1,114 @@ +"""The installer writes the shim, hook, workflow and Commands section once.""" + +from __future__ import annotations + +import stat +from pathlib import Path + +import pytest + +from repo_contract import install as inst +from repo_contract.check import violations + +from .conftest import good_repo, write + + +def python_repo(root: Path) -> Path: + """A python repo with tests and no contract pieces at all.""" + write(root, "pyproject.toml", "[project]\nname='x'\n") + write(root, "tests/test_a.py", "def test_a():\n pass\n") + return root + + +def test_plan_lists_everything_for_a_bare_repo(repo: Path) -> None: + assert inst.plan(repo) == [ + "scripts/check_repo_contract.sh", + ".github/workflows/repo-contract.yml", + ".pre-commit-config.yaml (repo-contract hook)", + ] + + +def test_install_writes_pieces_and_is_idempotent(repo: Path) -> None: + python_repo(repo) + done = inst.install(repo) + assert len(done) == 3 + shim = repo / inst.SHIM + assert shim.stat().st_mode & stat.S_IXUSR + assert "id: repo-contract" in (repo / inst.PRECOMMIT).read_text(encoding="utf-8") + assert inst.install(repo) == [] + assert inst.plan(repo) == [] + + +def test_marker_skips_workflow(repo: Path) -> None: + write(repo, ".dep-freshness-no-workflow") + inst.install(repo) + assert not (repo / inst.WORKFLOW).exists() + + +def test_utils_itself_is_not_given_a_shim(repo: Path) -> None: + write(repo, "repo_contract/check.py") + assert inst.install(repo) != [] + assert not (repo / inst.SHIM).exists() + + +def test_hook_lands_in_the_last_local_block(repo: Path) -> None: + write( + repo, + inst.PRECOMMIT.as_posix(), + "repos:\n - repo: local\n hooks:\n - id: a\n" + " - repo: https://example.com/x\n hooks:\n - id: b", + ) + inst.install(repo) + lines = (repo / inst.PRECOMMIT).read_text(encoding="utf-8").splitlines() + assert lines.index(" - id: repo-contract") < lines.index( + " - repo: https://example.com/x" + ) + + +def test_hook_needs_a_local_block(repo: Path) -> None: + write(repo, inst.PRECOMMIT.as_posix(), "repos:\n - repo: https://example.com/x\n") + with pytest.raises(ValueError, match="no ` - repo: local` block"): + inst.install(repo) + + +def test_bootstrap_commands_creates_section_and_script(repo: Path) -> None: + python_repo(repo) + actions = inst.bootstrap_commands(repo) + assert any("test_changed.sh" in a for a in actions) + assert "## Commands" in (repo / "CLAUDE.md").read_text(encoding="utf-8") + assert inst.bootstrap_commands(repo) == [] + + +def test_main_check_changes_nothing( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert inst.main([str(repo), "--check"]) == 1 + out = capsys.readouterr().out + assert "would write: scripts/check_repo_contract.sh" in out + assert not (repo / inst.SHIM).exists() + + +def test_main_installs_and_passes( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + good_repo(repo) + assert inst.main([str(repo)]) == 0 + assert "wrote: scripts/check_repo_contract.sh" in capsys.readouterr().out + assert violations(repo)[0] == [] + + +def test_main_no_bootstrap_leaves_violation(repo: Path) -> None: + assert inst.main([str(repo), "--no-bootstrap"]) == 1 + assert not (repo / "CLAUDE.md").exists() + + +def test_main_rejects_non_repo(tmp_path: Path) -> None: + assert inst.main([str(tmp_path)]) == 2 + + +def test_main_reports_bootstrap_actions( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + python_repo(repo) + inst.main([str(repo)]) + assert "bootstrap: created scripts/test_changed.sh" in capsys.readouterr().out diff --git a/scripts/install_repo_contract_gate.sh b/scripts/install_repo_contract_gate.sh new file mode 100755 index 0000000..2c3a0e2 --- /dev/null +++ b/scripts/install_repo_contract_gate.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# ============================================================================ +# Install the repo-contract gate into a repo and bootstrap its CLAUDE.md +# `## Commands` section plus scripts/test_changed.sh. +# +# Run this on EVERY new repo, right after `git init`. Idempotent. Pieces: +# scripts/check_repo_contract.sh shim, pre-commit hook, CI workflow (skipped +# when the repo has .dep-freshness-no-workflow), Commands section. +# +# Usage: scripts/install_repo_contract_gate.sh [--check] [--no-bootstrap] +# Exit: 0 contract satisfied | 1 violations left (e.g. ) | 2 not a git repo +# ============================================================================ + +set -euo pipefail + +UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly UTILS_ROOT + +main() { + if [[ $# -lt 1 ]]; then + echo "Usage: $(basename "$0") [--check] [--no-bootstrap]" >&2 + exit 1 + fi + PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + python3 -m repo_contract.install "$@" +} + +main "$@"