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

1#!/usr/bin/env python3 

2"""Refuse to commit an edit to a file the rhiza template owns. 

3 

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. 

9 

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. 

13 

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". 

17 

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. 

24 

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. 

32 

33Exit codes: 

34 0 - no managed file is being modified 

35 1 - at least one managed file is being modified 

36""" 

37 

38from __future__ import annotations 

39 

40import argparse 

41import subprocess # nosec B404 

42import sys 

43from pathlib import Path 

44 

45from rhiza_hooks._managed import CONFIG_PATH, managed_paths, template_repository 

46from rhiza_hooks._repo import find_repo_root 

47 

48 

49def repo_relative(filename: str, repo_root: Path) -> str: 

50 """Normalise a hook argument to a repo-relative POSIX path for comparison. 

51 

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. 

55 

56 Args: 

57 filename: Path as given on the command line. 

58 repo_root: Root directory of the repository. 

59 

60 Returns: 

61 The path, relative to ``repo_root`` where possible, with forward slashes. 

62 

63 >>> from pathlib import Path 

64 >>> repo_relative("Makefile", Path(".")) 

65 'Makefile' 

66 

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: 

70 

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() 

81 

82 

83def modified_paths(repo_root: Path) -> set[str] | None: 

84 """Return the tracked paths that differ from HEAD, staged or not. 

85 

86 Args: 

87 repo_root: Root directory of the repository. 

88 

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()} 

106 

107 

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) 

115 

116 

117def check_managed_files(filenames: list[str], repo_root: Path, allowed: set[str]) -> list[str]: 

118 """Report each given path that the template owns. 

119 

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``. 

124 

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 [] 

132 

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 ] 

139 

140 

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) 

157 

158 errors = check_managed_files(args.filenames, find_repo_root(), set(args.allow)) 

159 

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 ) 

167 

168 return 1 if errors else 0 

169 

170 

171if __name__ == "__main__": # pragma: no mutate 

172 sys.exit(main())