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

1#!/usr/bin/env python3 

2"""Work out which paths in this repo are owned by the template, not by the project. 

3 

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. 

10 

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

18 

19from __future__ import annotations 

20 

21from pathlib import Path 

22from typing import Any 

23 

24from rhiza_hooks._yaml import YamlFailure, load_yaml_mapping 

25 

26LOCK_PATH = Path(".rhiza") / "template.lock" 

27CONFIG_PATH = Path(".rhiza") / "template.yml" 

28 

29 

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. 

32 

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} 

41 

42 

43def managed_paths(repo_root: Path) -> set[str]: 

44 """Return the repo-relative paths the template owns. 

45 

46 Args: 

47 repo_root: Root directory of the repository. 

48 

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

58 

59 config = load_yaml_mapping(repo_root / CONFIG_PATH) 

60 excluded = set() if isinstance(config, YamlFailure) else _string_list(config, "exclude") 

61 

62 return _string_list(lock, "files") - excluded 

63 

64 

65def template_repository(repo_root: Path) -> str | None: 

66 """Return the template repository recorded by the last sync, e.g. ``owner/repo``. 

67 

68 Args: 

69 repo_root: Root directory of the repository. 

70 

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