Coverage for src/rhiza_hooks/_managed.py: 100%
24 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"""Work out which paths in this repo are owned by the template, not by the project.
4A rhiza-managed repo syncs its development infrastructure from a template repo.
5``.rhiza/template.lock`` records what the last sync wrote, and
6``.rhiza/template.yml`` records what the project deliberately opted out of. The
7set of *template-owned* paths is the first minus the second, and it is the answer
8several hooks need: editing such a path is pointless (the next sync overwrites
9it), and pointing release tooling at one is actively harmful.
11The subtraction is not optional. The lock's ``files:`` block lists every path the
12profile would deliver **including ones the project excludes** — and the lock's own
13``exclude:`` key does not mirror ``template.yml``'s (it is ``[]`` in a repo with two
14active exclusions). So ``template.yml`` is the only reliable source for the opt-outs,
15and a caller that trusted ``files:`` alone would flag files the project legitimately
16owns.
17"""
19from __future__ import annotations
21from pathlib import Path
22from typing import Any
24from rhiza_hooks._yaml import YamlFailure, load_yaml_mapping
26LOCK_PATH = Path(".rhiza") / "template.lock"
27CONFIG_PATH = Path(".rhiza") / "template.yml"
30def _string_list(data: dict[Any, Any], key: str) -> set[str]:
31 """Read ``key`` from ``data`` as a set of strings, tolerating any other shape.
33 A malformed value (absent, scalar, mapping) yields an empty set rather than an
34 error: these hooks report on project files, and a broken ``.rhiza/`` document is
35 ``check-rhiza-config``'s business to report, not theirs.
36 """
37 value = data.get(key)
38 if not isinstance(value, list):
39 return set()
40 return {str(item) for item in value}
43def managed_paths(repo_root: Path) -> set[str]:
44 """Return the repo-relative paths the template owns.
46 Args:
47 repo_root: Root directory of the repository.
49 Returns:
50 Paths listed in ``.rhiza/template.lock``'s ``files:`` block, minus those
51 the project excludes in ``.rhiza/template.yml``. Empty when the lock is
52 missing or unusable — a repo that is managed but not yet synced owns
53 everything, so callers then have nothing to enforce.
54 """
55 lock = load_yaml_mapping(repo_root / LOCK_PATH)
56 if isinstance(lock, YamlFailure):
57 return set()
59 config = load_yaml_mapping(repo_root / CONFIG_PATH)
60 excluded = set() if isinstance(config, YamlFailure) else _string_list(config, "exclude")
62 return _string_list(lock, "files") - excluded
65def template_repository(repo_root: Path) -> str | None:
66 """Return the template repository recorded by the last sync, e.g. ``owner/repo``.
68 Args:
69 repo_root: Root directory of the repository.
71 Returns:
72 The lock's ``repo`` value, or None when the lock is missing or does not
73 record one. Callers use it to name where a managed file should be changed
74 instead; the lock maps no file to its originating *bundle*, so the
75 repository is as specific as this can get.
76 """
77 lock = load_yaml_mapping(repo_root / LOCK_PATH)
78 if isinstance(lock, YamlFailure):
79 return None
80 repo = lock.get("repo")
81 return repo if isinstance(repo, str) else None