Coverage for src/rhiza_task/tasks/template.py: 100%

70 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-13 02:27 +0000

1"""The template sync, as one command, for people who are not driving it from Claude. 

2 

3Every other module here replaces a make recipe. This one replaces a *README section*: 

4jebel-quant/rhiza#1654 logs an upgrade that took eight documented steps, a clone into 

5``~/.local/share``, three scripts run in sequence and four commits, and the guide that came 

6out of it is wrong in one place that matters -- it says ``git add -A`` where the upstream 

7document says never to, because the lock records exactly which paths the sync materialised 

8and staging everything sweeps unrelated work into the sync commit. 

9 

10**This is a wrapper, and that is the whole design.** ``rhiza-claude``'s ``docs/headless.md`` 

11settles the question this module would otherwise re-open: 

12 

13 Nothing here is a re-implementation. ``/rhiza:update`` shells out to the very 

14 ``sync.py`` invocation shown below [...] A separate CLI would be a second 

15 implementation of sync to keep in step with the first, and that is the one thing this 

16 project has decided not to maintain. One operation, one entry point. 

17 

18So nothing below parses ``template.yml``, resolves a bundle, walks the lock or merges a 

19file. Those live in ``sync.py``, ``resolve_conflicts.py`` and ``stage_synced.py``, which are 

20stdlib-only, type-checked and covered in the repository that owns them. This module 

21provisions them, runs them in the documented order, reads the documented exit codes, and 

22stops. A third caller of one entry point is not a second implementation of it. 

23 

24**Why it lives here** rather than beside the scripts: ``rhiza-task`` is the only thing in 

25this ecosystem published to PyPI, so ``uvx rhiza-task update`` needs no install at all -- 

26and the install was the step the issue's users tripped over. The scripts stay where they 

27are; only the front door moves to where a consumer already is. 

28 

29**Where it stops, and why it stops there.** After ``stage_synced.py`` the change is on disk 

30and staged, and this task prints the commit command instead of running it. Two reasons, and 

31neither is timidity. The commit needs ``SKIP=check-managed-files`` -- rhiza-hooks refuses a 

32commit touching any path in the lock's ``files:`` list, and this commit is by construction 

33exactly that list -- which is a thing a person should see once rather than have hidden. And 

34a sync that reported conflicts has just resolved them by taking the template's side, which 

35is right for a managed file and is still a diff worth reading before it is history. 

36 

37**This is the one task in the package that this repository cannot dogfood.** ``ci.yml``'s 

38``gates`` job runs ``rhiza-task all`` against this tree, and there is no ``.rhiza/`` here to 

39sync -- CLAUDE.md's first section says why, at length. So the guard below skips, and the 

40vectors are asserted the way every other module's are, plus a scratch-repo job in CI. That 

41is a real gap and worth naming rather than leaving for someone to discover: nothing here 

42proves ``sync.py`` still takes the arguments this module passes it. What would prove it is a 

43job in *rhiza-claude* running its scripts against this front door, which is that 

44repository's call to make. 

45""" 

46 

47from __future__ import annotations 

48 

49import os 

50from pathlib import Path 

51 

52from ..config import Config 

53from ..spec import Failed, Guard, task 

54from ..uv import tool, uv_run 

55 

56SCRIPTS_REPO = "https://github.com/Jebel-Quant/rhiza-claude" 

57"""Where the sync scripts come from. Not a setting: which implementation of the template 

58sync is authoritative is not a per-repository choice, and a repo pointing this at a fork 

59would be running a second implementation -- the thing the module docstring rules out.""" 

60 

61SCRIPTS_SUBDIR = "plugin/scripts" 

62"""Where the scripts sit inside that clone. ``headless.md`` spells the same path into its 

63``$RHIZA`` export, so a reader following either document lands in the same directory.""" 

64 

65SCRIPTS_PYTHON = "3.12" 

66"""The interpreter the scripts are run under. 

67 

68``headless.md`` is unambiguous: ``tomllib`` and ``datetime.UTC`` put their floor at 3.11, 

69but 3.12 is what every command pins and the only version their CI exercises -- and a bare 

70``python3`` on macOS is 3.9, which crashes ``sync.py``. Pinning it here is what stops this 

71front door being the one that reintroduces that crash. It is deliberately *not* tied to 

72:attr:`Config.python_version`, which is the target repository's interpreter and has nothing 

73to say about a different project's scripts.""" 

74 

75TEMPLATE_YML = ".rhiza/template.yml" 

76"""The pointer file. Its presence is what makes a repository rhiza-managed, so it is also 

77the guard: a repo without one has no template to sync and skips rather than fails.""" 

78 

79REF_KEYS = ("template-branch", "ref") 

80"""The keys that can hold the template ref, in the precedence the sync itself applies. 

81 

82Not a preference: ``_validate_fields.py`` picks ``template-branch`` over ``ref`` when a file 

83has both, so this order is a mirror of that one. Reversing it would produce a pointer file 

84that disagrees with the version actually synced, with nothing non-zero anywhere to say so.""" 

85 

86CONFLICTS = 1 

87"""``sync.py``'s exit code for "synced, with conflicts" -- an expected outcome, not an error. 

88 

89The three codes are a contract that document states: 0 synced cleanly or already current, 

901 synced with conflicts and the merged files are on disk, 2 refused and applied nothing. 

91Only 1 is named here because 0 needs no branch and anything else is a failure; treating 1 as 

92an error is the mistake this constant exists to make hard.""" 

93 

94 

95def _cache_root() -> Path: 

96 """Return the directory this module keeps its own clone of the scripts in. 

97 

98 ``XDG_CACHE_HOME`` is honoured because it is the standard answer and because it is what 

99 makes the clone path testable without writing to a real home directory. 

100 

101 Returns: 

102 The clone directory, which may not exist yet. 

103 """ 

104 base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") 

105 return Path(base).expanduser() / "rhiza-task" / "rhiza-claude" 

106 

107 

108def _ensure_scripts(cfg: Config) -> Path: 

109 """Return the scripts directory, cloning it first when this module owns the copy. 

110 

111 Two cases, and the difference between them is who the clone belongs to. With 

112 ``RHIZA_CLAUDE_DIR`` set the directory is *someone else's* -- most likely the 

113 ``~/.local/share/rhiza-claude`` that ``headless.md`` tells people to make -- so it is 

114 used exactly as found and never written to. A ``git reset --hard`` into a working clone 

115 would discard whatever the person had in it, which is not a thing a sync command should 

116 be able to do to you. 

117 

118 The cache copy is ours, so it is refreshed: a stale ``sync.py`` is a correctness risk, 

119 and here there is nothing to lose by moving it. 

120 

121 Args: 

122 cfg: The resolved config, for the working directory git is invoked from. 

123 

124 Returns: 

125 The directory holding ``sync.py`` and its siblings. 

126 

127 Raises: 

128 Failed: When ``RHIZA_CLAUDE_DIR`` names a directory with no scripts in it. Silently 

129 falling back to the cache would run a different copy than the one asked for. 

130 """ 

131 override = os.environ.get("RHIZA_CLAUDE_DIR") 

132 if override: 

133 scripts = Path(override).expanduser() / SCRIPTS_SUBDIR 

134 if not scripts.is_dir(): 

135 msg = f"RHIZA_CLAUDE_DIR is set to {override}, which has no {SCRIPTS_SUBDIR}/" 

136 raise Failed(2, msg) 

137 return scripts 

138 

139 cache = _cache_root() 

140 if (cache / ".git").is_dir(): 

141 tool("git", "-C", str(cache), "fetch", "--depth", "1", "origin", "HEAD", cwd=cfg.root) 

142 tool("git", "-C", str(cache), "reset", "--hard", "FETCH_HEAD", cwd=cfg.root) 

143 else: 

144 cache.parent.mkdir(parents=True, exist_ok=True) 

145 tool("git", "clone", "--depth", "1", SCRIPTS_REPO, str(cache), cwd=cfg.root) 

146 return cache / SCRIPTS_SUBDIR 

147 

148 

149def _bump_ref(path: Path, ref: str) -> None: 

150 r"""Rewrite the pointer's ref line, and nothing else in the file. 

151 

152 **Which key holds the ref is not a fixed answer**, and getting it wrong fails silently 

153 rather than loudly. ``_validate_fields.py`` resolves it as ``"template-branch" if 

154 "template-branch" in config else "ref"`` -- so in a file carrying both, ``template-branch`` 

155 is what the sync obeys. Rewriting ``ref:`` there would leave the file *claiming* the new 

156 version while the sync fetched the old one, and every exit code would be 0. The same 

157 precedence is applied here for exactly that reason. 

158 

159 The anchor is column zero, copied from ``headless.md``'s own 

160 ``sed -i "s/^ref: .*/ref: \"$TARGET\"/"``: ``profiles:``, ``templates:``, ``exclude:`` and 

161 ``language:`` are a separate decision a version bump must not carry, and matching at any 

162 indentation would reach a nested key inside them. 

163 

164 Args: 

165 path: The ``.rhiza/template.yml`` to edit. 

166 ref: The target ref, e.g. ``v1.8.0``. 

167 

168 Raises: 

169 Failed: When the file holds neither key at top level. Appending one would be a guess 

170 about a file this module has already said it does not parse. 

171 """ 

172 lines = path.read_text().splitlines(keepends=True) 

173 for key in REF_KEYS: 

174 if not any(line.startswith(f"{key}:") for line in lines): 

175 continue 

176 rewritten = [f'{key}: "{ref}"\n' if line.startswith(f"{key}:") else line for line in lines] 

177 path.write_text("".join(rewritten)) 

178 return 

179 msg = f"{TEMPLATE_YML} has no top-level {' or '.join(REF_KEYS)} line to bump" 

180 raise Failed(2, msg) 

181 

182 

183def _commit_bump(cfg: Config) -> None: 

184 """Commit the pointer bump, because ``sync.py`` refuses to run with it uncommitted. 

185 

186 This is the one commit the task makes, and it is not a relaxation of the rule that the 

187 *sync* commit is the caller's -- it is a precondition of getting a sync at all. 

188 ``headless.md`` has it as step 2 for the same reason, and a real run is what proved it 

189 load-bearing: bump, then sync, and sync reports ``Working tree is not clean`` naming the 

190 file this task just wrote. Vector tests cannot find that, since the refusal is a fact 

191 about ``sync.py`` rather than about the arguments handed to it. 

192 

193 The pathspec is not decoration. ``headless.md`` says ``git commit -am``, which would 

194 sweep every other modified tracked file into a commit whose message claims to be a 

195 version bump; naming the path commits that path and nothing else. A tree dirty for other 

196 reasons is then still refused by ``sync.py``, which is upstream's call to make and not 

197 this module's to pre-empt. 

198 

199 Args: 

200 cfg: The resolved config. 

201 """ 

202 tool("git", "commit", "-m", f"chore: bump rhiza to {cfg.template_ref}", "--", TEMPLATE_YML, cwd=cfg.root) 

203 

204 

205def _script(cfg: Config, scripts: Path, name: str, *args: str, check: bool = True) -> int: 

206 """Run one headless script against the target repository. 

207 

208 ``--no-project`` is not optional: without it uv resolves the *target* repo's environment 

209 for a script that imports nothing outside the standard library, which at best is a slow 

210 no-op and at worst fails in a repo whose own dependencies do not resolve. 

211 

212 Args: 

213 cfg: The resolved config. 

214 scripts: The directory holding the scripts. 

215 name: The script's filename, e.g. ``sync.py``. 

216 *args: Arguments after the repository path. 

217 check: Raise on a non-zero exit rather than returning it. 

218 

219 Returns: 

220 The script's exit status. 

221 """ 

222 return uv_run( 

223 "python", 

224 str(scripts / name), 

225 str(cfg.root), 

226 *args, 

227 cwd=cfg.root, 

228 no_project=True, 

229 python=SCRIPTS_PYTHON, 

230 check=check, 

231 ) 

232 

233 

234@task( 

235 "update", 

236 "sync the rhiza template into this repository", 

237 section="Template", 

238 # No `Guard(tool="git")`, and that is a rule rather than an omission: `doctor` names uv 

239 # and git as the two prerequisites a process running `uvx rhiza-task` cannot do without, 

240 # and fails on a miss. Guarding a task on one of them would demote that hard requirement 

241 # to a per-task skip, so `test_probes_nothing_a_guard_already_owns` asserts the two sets 

242 # are disjoint -- and caught this guard on the first run. A machine with no git gets the 

243 # OS's own message from the clone, which is what `uv.py`'s `_bin` already settles for uv. 

244 guards=(Guard(file=TEMPLATE_YML, reason=f"no {TEMPLATE_YML}; this repository is not rhiza-managed"),), 

245) 

246def update(cfg: Config) -> None: 

247 """Bump the template pointer if asked, then sync, resolve and stage. 

248 

249 The four steps ``headless.md`` documents, in its order and reading its exit codes. What 

250 is left to the caller is the commit, for the reasons the module docstring gives. 

251 

252 Args: 

253 cfg: The resolved config. :attr:`Config.template_ref` is the ref to move to; empty 

254 re-syncs at whatever ``template.yml`` already names. 

255 

256 Raises: 

257 Failed: When ``sync.py`` refused -- a dirty tree, an invalid ``template.yml`` or a 

258 git failure -- or when a later step failed. A refusal applies nothing, so there 

259 is no half-done state to unpick. 

260 """ 

261 scripts = _ensure_scripts(cfg) 

262 if cfg.template_ref: 

263 _bump_ref(cfg.root / TEMPLATE_YML, cfg.template_ref) 

264 _commit_bump(cfg) 

265 print(f"[INFO] {TEMPLATE_YML} now points at {cfg.template_ref}") 

266 

267 code = _script(cfg, scripts, "sync.py", check=False) 

268 if code == CONFLICTS: 

269 # Taking the template's side of every marker is what resolve_conflicts.py does, and 

270 # it is right rather than merely convenient: a rhiza-managed file is the template's 

271 # to own, so local divergence in one is drift to undo, not work to preserve. 

272 print("[INFO] sync reported conflicts; taking the template's side") 

273 _script(cfg, scripts, "resolve_conflicts.py") 

274 elif code: 

275 # Naming the bump matters when there was one: the pointer is committed by then, so 

276 # "applied nothing" is true of the sync and not of the run, and a reader with a dirty 

277 # tree needs to know there is a commit to drop. 

278 detail = "sync refused and applied nothing: dirty tree, invalid template.yml, or git failure" 

279 if cfg.template_ref: 

280 detail += f" (the bump to {cfg.template_ref} is already committed)" 

281 raise Failed(code, detail) 

282 

283 _script(cfg, scripts, "stage_synced.py", "--json") 

284 target = cfg.template_ref or "the pinned ref" 

285 print("[INFO] staged the synced files. To commit exactly that set:") 

286 print(f'[INFO] SKIP=check-managed-files git commit -m "chore: apply rhiza sync {target}"')