Coverage for src/rhiza_hooks/check_managed_files.py: 100%
46 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Refuse to commit an edit to a file the rhiza template owns.
4A rhiza-managed repo syncs its development infrastructure from a template repo,
5and every such repo's CLAUDE.md opens with the same rule: do not edit the managed
6files, because the next sync overwrites them. Until this hook there was nothing
7enforcing it — `make validate`, the one drift check that existed, was removed
8upstream after rhiza v1.1.3.
10So the failure mode was silent and total: edit a managed file, watch it work, get
11it reviewed and merged, and lose it at the next sync with no error at any point.
12This hook makes that loud at the commit that causes it.
14The check is path-based, because ``.rhiza/template.lock`` records paths and no
15content hashes. That is the right signal anyway — the objection is not "your edit
16is wrong" but "this file is not yours to edit".
18Only a file that actually **differs from HEAD** is reported, not merely one that is
19managed and present. pre-commit normally passes just the staged paths, but under
20``--all-files`` — which ``make fmt`` and CI use — it passes every tracked file, and
21without this the hook would report all sixty-odd managed files on a clean tree.
22When git cannot answer (no HEAD yet, or no git at all) the hook falls back to
23trusting the paths it was given, which is pre-commit's normal contract.
25Bypassing it:
26 - a path listed under ``exclude:`` in ``.rhiza/template.yml`` is not synced, so it
27 is not managed and never reported (see :mod:`rhiza_hooks._managed`);
28 - ``--allow PATH`` waives one path for a deliberate, knowingly-temporary override;
29 - ``SKIP=check-managed-files git commit`` waives the whole hook, which is what a
30 ``rhiza sync`` commit needs, since rewriting managed files wholesale is exactly
31 its job.
33Exit codes:
34 0 - no managed file is being modified
35 1 - at least one managed file is being modified
36"""
38from __future__ import annotations
40import argparse
41import subprocess # nosec B404
42import sys
43from pathlib import Path
45from rhiza_hooks._managed import CONFIG_PATH, managed_paths, template_repository
46from rhiza_hooks._repo import find_repo_root
49def repo_relative(filename: str, repo_root: Path) -> str:
50 """Normalise a hook argument to a repo-relative POSIX path for comparison.
52 pre-commit already passes repo-relative paths, but a hand-run invocation may
53 pass absolute or ``./``-prefixed ones. A path outside the repository is
54 returned as-is: it cannot match a managed path, so it is reported by nobody.
56 Args:
57 filename: Path as given on the command line.
58 repo_root: Root directory of the repository.
60 Returns:
61 The path, relative to ``repo_root`` where possible, with forward slashes.
63 >>> from pathlib import Path
64 >>> repo_relative("Makefile", Path("."))
65 'Makefile'
67 A ``./`` prefix is normalised away, and separators come out as forward
68 slashes on every platform, so comparison against the managed-path list is
69 not OS-dependent:
71 >>> repo_relative("./.github/workflows/rhiza_ci.yml", Path("."))
72 '.github/workflows/rhiza_ci.yml'
73 """
74 path = Path(filename)
75 if path.is_absolute():
76 try:
77 path = path.relative_to(repo_root)
78 except ValueError:
79 return path.as_posix()
80 return path.as_posix()
83def modified_paths(repo_root: Path) -> set[str] | None:
84 """Return the tracked paths that differ from HEAD, staged or not.
86 Args:
87 repo_root: Root directory of the repository.
89 Returns:
90 Repo-relative paths with changes, or None when git cannot answer — no git on
91 PATH, no commits yet, or not a work tree. The caller then trusts the paths it
92 was handed instead of narrowing them.
93 """
94 try:
95 result = subprocess.run( # nosec B603 B607
96 ["git", "diff", "--name-only", "HEAD"], # noqa: S607
97 cwd=repo_root,
98 capture_output=True,
99 text=True,
100 check=True,
101 timeout=30,
102 )
103 except (OSError, subprocess.SubprocessError):
104 return None
105 return {line.strip() for line in result.stdout.splitlines() if line.strip()}
108def _offenders(filenames: list[str], repo_root: Path, managed: set[str], allowed: set[str]) -> list[str]:
109 """Return the managed paths being modified among ``filenames``, in a stable order."""
110 given = {repo_relative(filename, repo_root) for filename in filenames}
111 modified = modified_paths(repo_root)
112 if modified is not None:
113 given &= modified
114 return sorted((given & managed) - allowed)
117def check_managed_files(filenames: list[str], repo_root: Path, allowed: set[str]) -> list[str]:
118 """Report each given path that the template owns.
120 Args:
121 filenames: Paths being committed, as passed by pre-commit.
122 repo_root: Root directory of the repository.
123 allowed: Paths waived via ``--allow``.
125 Returns:
126 List of error messages, one per offending path (empty when none is managed).
127 """
128 managed = managed_paths(repo_root)
129 if not managed:
130 # Not managed, or managed but never synced: nothing is owned upstream.
131 return []
133 origin = template_repository(repo_root) or "the template repository"
134 return [
135 f"{path} is owned by {origin} and will be overwritten by the next sync. "
136 f"Change it upstream and re-sync, or add it to exclude: in {CONFIG_PATH.as_posix()}."
137 for path in _offenders(filenames, repo_root, managed, allowed)
138 ]
141def main(argv: list[str] | None = None) -> int:
142 """Run the hook and return a process exit code."""
143 parser = argparse.ArgumentParser(description="Refuse edits to files owned by the rhiza template")
144 parser.add_argument(
145 "filenames",
146 nargs="*",
147 help="Filenames to check (passed by pre-commit)",
148 )
149 parser.add_argument(
150 "--allow",
151 action="append",
152 default=[],
153 metavar="PATH",
154 help="Waive one managed path; repeatable. Prefer exclude: in .rhiza/template.yml for anything permanent.",
155 )
156 args = parser.parse_args(argv)
158 errors = check_managed_files(args.filenames, find_repo_root(), set(args.allow))
160 for error in errors:
161 print(f"ERROR: {error}", file=sys.stderr)
162 if errors:
163 print(
164 "Bypass this hook for a sync commit with: SKIP=check-managed-files git commit ...",
165 file=sys.stderr,
166 )
168 return 1 if errors else 0
171if __name__ == "__main__": # pragma: no mutate
172 sys.exit(main())