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
« 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.
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.
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:
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.
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.
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.
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.
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"""
47from __future__ import annotations
49import os
50from pathlib import Path
52from ..config import Config
53from ..spec import Failed, Guard, task
54from ..uv import tool, uv_run
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."""
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."""
65SCRIPTS_PYTHON = "3.12"
66"""The interpreter the scripts are run under.
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."""
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."""
79REF_KEYS = ("template-branch", "ref")
80"""The keys that can hold the template ref, in the precedence the sync itself applies.
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."""
86CONFLICTS = 1
87"""``sync.py``'s exit code for "synced, with conflicts" -- an expected outcome, not an error.
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."""
95def _cache_root() -> Path:
96 """Return the directory this module keeps its own clone of the scripts in.
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.
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"
108def _ensure_scripts(cfg: Config) -> Path:
109 """Return the scripts directory, cloning it first when this module owns the copy.
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.
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.
121 Args:
122 cfg: The resolved config, for the working directory git is invoked from.
124 Returns:
125 The directory holding ``sync.py`` and its siblings.
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
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
149def _bump_ref(path: Path, ref: str) -> None:
150 r"""Rewrite the pointer's ref line, and nothing else in the file.
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.
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.
164 Args:
165 path: The ``.rhiza/template.yml`` to edit.
166 ref: The target ref, e.g. ``v1.8.0``.
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)
183def _commit_bump(cfg: Config) -> None:
184 """Commit the pointer bump, because ``sync.py`` refuses to run with it uncommitted.
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.
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.
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)
205def _script(cfg: Config, scripts: Path, name: str, *args: str, check: bool = True) -> int:
206 """Run one headless script against the target repository.
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.
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.
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 )
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.
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.
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.
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}")
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)
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}"')