Coverage for plugin/scripts/status.py: 100%

129 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Show the current rhiza sync status from `.rhiza/template.lock`. 

3 

4A stdlib-only port of the `rhiza status` command, bundled with this plugin so 

5`/rhiza:status` works without the `rhiza` CLI (or PyYAML) installed. It reads 

6the authoritative lock written by the last sync and reports the template 

7repository, ref, SHA, sync timestamp, strategy, and included templates/paths. 

8With `--files` it also renders the managed files as a directory tree — the view 

9that used to live in the separate `/rhiza:tree` command. 

10 

11Usage: 

12 uv run --python 3.12 --no-project python scripts/status.py [TARGET] [--json] [--files] [--check] 

13 

14 TARGET repository root to inspect (default: current directory) 

15 --json emit a single JSON object on stdout instead of human-readable lines 

16 --files append the managed files as a `tree`-style listing (human output) 

17 --check compare the pinned ref to the latest upstream release (needs network) 

18 

19When no lock is present the repo has never been synced; this prints a hint to 

20stderr and exits 0 (nothing to report is not an error). The `--json` payload 

21mirrors `rhiza status --json` field-for-field (including `files`), so tools like 

22stats.py can read either interchangeably. Where the CLI uses `rich` for the file 

23tree, this renders plain ASCII connectors so it stays dependency-free. 

24 

25Everything is offline and deterministic except `--check`, which shells out to 

26`git ls-remote --tags` (no `gh`, no auth for public repos) to see whether a newer 

27release exists; a network or git failure there is reported, never fatal. 

28""" 

29 

30from __future__ import annotations 

31 

32import argparse 

33import json 

34import re 

35import shutil 

36import subprocess # nosec B404 

37import sys 

38from pathlib import Path 

39from typing import Any 

40 

41_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") 

42 

43sys.path.insert(0, str(Path(__file__).resolve().parent)) 

44from _rhiza_yaml import load_yaml # noqa: E402 

45 

46LOCK_REL = Path(".rhiza") / "template.lock" 

47 

48 

49def _as_list(value: Any) -> list[str]: 

50 """Normalise a scalar/None/list lock field to a list of strings.""" 

51 if value is None: 

52 return [] 

53 if isinstance(value, list): 

54 return [str(x) for x in value] 

55 return [str(value)] 

56 

57 

58def _status_dict(lock: dict[str, Any]) -> dict[str, Any]: 

59 """Build the machine-readable status payload from a parsed lock. 

60 

61 Mirrors the field set emitted by `rhiza status --json`. 

62 """ 

63 host = str(lock.get("host", "github")) 

64 repo = str(lock.get("repo", "")) 

65 return { 

66 "repository": f"{host}/{repo}" if repo else host, 

67 "host": host, 

68 "repo": repo, 

69 "ref": str(lock.get("ref", "main")), 

70 "sha": str(lock.get("sha", "")), 

71 "synced_at": str(lock.get("synced_at", "")), 

72 "strategy": str(lock.get("strategy", "")), 

73 "templates": _as_list(lock.get("templates")), 

74 "include": _as_list(lock.get("include")), 

75 "files": _as_list(lock.get("files")), 

76 } 

77 

78 

79def _build_tree(paths: list[str]) -> dict[str, Any]: 

80 """Fold a flat path list into a nested {name: subtree} dict.""" 

81 root: dict[str, Any] = {} 

82 for path in sorted(paths): 

83 node = root 

84 for part in Path(path).parts: 

85 node = node.setdefault(part, {}) 

86 return root 

87 

88 

89def _render(node: dict[str, Any], prefix: str = "") -> list[str]: 

90 """Render a nested tree dict into Unix-`tree`-style lines.""" 

91 lines: list[str] = [] 

92 items = sorted(node.items()) 

93 for i, (name, child) in enumerate(items): 

94 last = i == len(items) - 1 

95 lines.append(f"{prefix}{'└── ' if last else '├── '}{name}") 

96 if child: 

97 lines.extend(_render(child, prefix + (" " if last else "│ "))) 

98 return lines 

99 

100 

101def _print_file_tree(files: list[str]) -> None: 

102 """Print the managed files as a `tree`-style listing plus a total count.""" 

103 if not files: 

104 print("No files are tracked in template.lock", file=sys.stderr) 

105 return 

106 print("\nFiles managed by Rhiza:") 

107 print(".") 

108 for line in _render(_build_tree(files)): 

109 print(line) 

110 print(f"\n{len(files)} file{'s' if len(files) != 1 else ''} managed by Rhiza") 

111 

112 

113def _parse_semver(tag: str) -> tuple[int, int, int] | None: 

114 """Parse a `vX.Y.Z` tag into a (major, minor, patch) tuple, or None.""" 

115 m = _SEMVER_RE.match(tag) 

116 return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None 

117 

118 

119def _remote_url(host: str, repo: str) -> str: 

120 """Build an HTTPS clone URL for the given host alias and owner/repo slug.""" 

121 base = "gitlab.com" if "gitlab" in host else "github.com" 

122 return f"https://{base}/{repo}" 

123 

124 

125def _remote_tags(host: str, repo: str) -> list[str]: 

126 """Return the remote's tag names via `git ls-remote --tags`, or [] on failure. 

127 

128 The git binary is resolved through `shutil.which` rather than left to a ``PATH`` 

129 lookup at exec time, matching every other call site here. A bare ``"git"`` is 

130 resolved against whatever ``PATH`` happens to hold, which is the one difference 

131 between this and the rest of the module (ruff's S607). 

132 """ 

133 git = shutil.which("git") 

134 if git is None: # pragma: no cover - git is present everywhere this runs 

135 return [] 

136 try: 

137 proc = subprocess.run( # nosec B603 # noqa: S603 - a fixed argv, never a shell 

138 [git, "ls-remote", "--tags", _remote_url(host, repo)], 

139 capture_output=True, 

140 text=True, 

141 check=True, 

142 timeout=20, 

143 ) 

144 except (OSError, subprocess.SubprocessError): 

145 return [] 

146 tags: set[str] = set() 

147 for line in proc.stdout.splitlines(): 

148 _, _, name = line.partition("refs/tags/") 

149 if name: 

150 tags.add(name.removesuffix("^{}")) 

151 return sorted(tags) 

152 

153 

154Release = tuple[str, tuple[int, ...]] 

155 

156 

157def _releases(tags: list[str]) -> list[Release]: 

158 """Pair each tag that parses as a release with its version, dropping the rest.""" 

159 return [(tag, version) for tag in tags if (version := _parse_semver(tag)) is not None] 

160 

161 

162def _behind_count(releases: list[Release], current: tuple[int, ...]) -> int: 

163 """Count how many of *releases* are newer than *current*.""" 

164 return sum(1 for _, version in releases if version > current) 

165 

166 

167def _outdated_message(ref: str, tags: list[str]) -> str: 

168 """Compose a one-line 'update available?' summary for *ref* against *tags*.""" 

169 releases = _releases(tags) 

170 if not releases: 

171 return "Update : could not determine the latest release" 

172 latest_tag, _ = max(releases, key=lambda pair: pair[1]) 

173 current = _parse_semver(ref) 

174 if current is None: 

175 return ( 

176 f"Update : latest release is {latest_tag} " 

177 f"(current ref '{ref}' is not a release tag) — run /rhiza:update, or sync.py" 

178 ) 

179 behind = _behind_count(releases, current) 

180 if behind == 0: 

181 return f"Update : up to date ({latest_tag} is the latest release)" 

182 plural = "s" if behind != 1 else "" 

183 return ( 

184 f"Update : {ref}{latest_tag} ({behind} release{plural} behind) " 

185 "— run /rhiza:update, or sync.py" 

186 ) 

187 

188 

189def _print_outdated(payload: dict[str, Any]) -> None: 

190 """Print the outdated-check line for a status payload (network via git).""" 

191 if not payload["repo"]: 

192 print("Update : no template repository recorded in the lock") 

193 return 

194 tags = _remote_tags(payload["host"], payload["repo"]) 

195 print(_outdated_message(payload["ref"], tags)) 

196 

197 

198def _print_summary(payload: dict[str, Any]) -> None: 

199 """Print the human-readable header block for a lock file. 

200 

201 `templates` and `include` are alternatives, not both — the lock records whichever 

202 selection mode the pointer used, so printing one or the other keeps the output honest 

203 about which mode is in force. 

204 """ 

205 print(f"Repository : {payload['repository']}") 

206 print(f"Ref : {payload['ref']}") 

207 sha = payload["sha"] 

208 print(f"SHA : {sha[:12]}" if sha else "SHA : (unknown)") 

209 print(f"Synced at : {payload['synced_at'] or '(unknown)'}") 

210 print(f"Strategy : {payload['strategy'] or '(unknown)'}") 

211 if payload["templates"]: 

212 print(f"Templates : {', '.join(payload['templates'])}") 

213 elif payload["include"]: 

214 print(f"Include : {', '.join(payload['include'])}") 

215 

216 

217def status( 

218 target: Path, 

219 *, 

220 json_output: bool = False, 

221 show_files: bool = False, 

222 check: bool = False, 

223) -> int: 

224 """Print the sync status; return a process exit code.""" 

225 lock_path = (target / LOCK_REL).resolve() 

226 if not lock_path.exists(): 

227 print( 

228 "No template.lock found — this repo has never been synced. " 

229 "Run /rhiza:update, or sync.py directly.", 

230 file=sys.stderr, 

231 ) 

232 return 0 

233 

234 try: 

235 lock = load_yaml(lock_path) 

236 except (OSError, ValueError) as exc: 

237 print(f"Could not read {lock_path}: {exc}", file=sys.stderr) 

238 return 1 

239 

240 payload = _status_dict(lock) 

241 if json_output: 

242 print(json.dumps(payload, indent=2)) 

243 return 0 

244 

245 _print_summary(payload) 

246 if check: 

247 _print_outdated(payload) 

248 if show_files: 

249 _print_file_tree(payload["files"]) 

250 return 0 

251 

252 

253def main(argv: list[str] | None = None) -> int: 

254 """Entry point: print status; return an exit code.""" 

255 parser = argparse.ArgumentParser( 

256 description="Show the current rhiza sync status from .rhiza/template.lock.", 

257 ) 

258 parser.add_argument( 

259 "target", 

260 nargs="?", 

261 default=".", 

262 help="Repository root to inspect (default: current directory).", 

263 ) 

264 parser.add_argument( 

265 "--json", 

266 dest="json_output", 

267 action="store_true", 

268 help="Emit the status as a single JSON object on stdout.", 

269 ) 

270 parser.add_argument( 

271 "--files", 

272 "--tree", 

273 dest="show_files", 

274 action="store_true", 

275 help="Append the managed files as a tree-style listing (human output).", 

276 ) 

277 parser.add_argument( 

278 "--check", 

279 dest="check", 

280 action="store_true", 

281 help="Compare the pinned ref to the latest upstream release (needs network).", 

282 ) 

283 args = parser.parse_args(argv) 

284 return status( 

285 Path(args.target), 

286 json_output=args.json_output, 

287 show_files=args.show_files, 

288 check=args.check, 

289 ) 

290 

291 

292if __name__ == "__main__": 

293 raise SystemExit(main())