mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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 <FILL IN> remains. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjyHmRrjZssaxXDWcGuCeQ
This commit is contained in:
parent
3f7af48a2b
commit
6d96c58f01
@ -54,10 +54,19 @@ is written as `<FILL IN>`, 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 <repo> # --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 `<FILL IN>` 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
|
||||
|
||||
|
||||
169
repo_contract/install.py
Normal file
169
repo_contract/install.py
Normal file
@ -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())
|
||||
114
repo_contract/tests/test_install.py
Normal file
114
repo_contract/tests/test_install.py
Normal file
@ -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
|
||||
29
scripts/install_repo_contract_gate.sh
Executable file
29
scripts/install_repo_contract_gate.sh
Executable file
@ -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 <repo> [--check] [--no-bootstrap]
|
||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 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") <repo> [--check] [--no-bootstrap]" >&2
|
||||
exit 1
|
||||
fi
|
||||
PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
python3 -m repo_contract.install "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue
Block a user