Coverage for src / rhiza / commands / sync.py: 100%

126 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-07-16 12:51 +0000

1"""Command for syncing Rhiza template files using diff/merge. 

2 

3This module implements the ``sync`` command. It uses a cruft-style diff/patch 

4approach so that local customisations are preserved and upstream changes 

5are applied safely. 

6 

7The approach: 

81. Read the last-synced commit SHA from ``.rhiza/template.lock``. 

92. Clone the template repository and obtain two tree snapshots: 

10 - **base**: the template at the previously synced commit (the common ancestor). 

11 - **upstream**: the template at the current HEAD of the configured branch. 

123. Compute a diff between base and upstream using ``git diff --no-index``. 

134. Apply the diff to the project using ``git apply -3`` for a 3-way merge. 

145. Update the lock file. 

15 

16When no lock file exists (first sync), the command falls back to a simple 

17copy and records the commit SHA. 

18""" 

19 

20import dataclasses 

21import datetime 

22import shutil 

23import tempfile 

24from collections.abc import Mapping 

25from pathlib import Path 

26from typing import TYPE_CHECKING 

27 

28import yaml 

29from loguru import logger 

30 

31from rhiza.models import GitContext, RhizaTemplate, TemplateLock 

32from rhiza.models._git.snapshot import _excluded_set, _prepare_snapshot 

33 

34if TYPE_CHECKING: 

35 from rhiza.models.bundle import RhizaBundles 

36 

37__all__ = ["sync"] 

38 

39_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml" 

40 

41 

42def _log_list(header: str, items: list[str]) -> None: 

43 """Log a labelled list of items, if non-empty. 

44 

45 Args: 

46 header: Label printed before the list. 

47 items: Items to log; nothing is printed when the list is empty. 

48 """ 

49 if items: 

50 logger.info(f"{header}:") 

51 for item in items: 

52 logger.info(f" - {item}") 

53 

54 

55def _load_template_from_project(target: Path, template_file: Path | None = None) -> RhizaTemplate: 

56 """Load a :class:`RhizaTemplate` from a project directory. 

57 

58 Loads the configuration with :meth:`~rhiza.models.RhizaTemplate.from_yaml` 

59 and checks that the required fields are present. 

60 

61 Args: 

62 target: Path to the target repository (must contain ``.git`` and 

63 ``.rhiza/template.yml``). 

64 template_file: Optional explicit path to the template file. When 

65 ``None`` the default ``<target>/.rhiza/template.yml`` is used. 

66 

67 Returns: 

68 The loaded :class:`RhizaTemplate`. 

69 

70 Raises: 

71 RuntimeError: If the template file is missing, malformed, or missing 

72 required fields. 

73 """ 

74 if template_file is None: 

75 template_file = target / ".rhiza" / "template.yml" 

76 

77 try: 

78 template = RhizaTemplate.from_yaml(template_file) 

79 except (FileNotFoundError, yaml.YAMLError, ValueError, TypeError) as exc: 

80 logger.error(f"Rhiza template is invalid in: {target}") 

81 logger.error(f"{exc}") 

82 logger.error("Fix the errors above and run 'rhiza sync' again") 

83 raise RuntimeError("Rhiza template validation failed") from exc # noqa: TRY003 

84 

85 # When template_bundles_path is at its default and the template file is not at 

86 # the default location, derive the bundles path from the template file's directory 

87 # relative to the project root so that --path-to-template works consistently. 

88 if template.template_bundles_path == _DEFAULT_BUNDLES_PATH: 

89 try: 

90 relative_dir = template_file.resolve().parent.relative_to(target) 

91 derived = (relative_dir / "template-bundles.yml").as_posix() 

92 if derived != _DEFAULT_BUNDLES_PATH: 

93 template = dataclasses.replace(template, template_bundles_path=derived) 

94 except ValueError: 

95 pass # template_file is outside target root; keep default 

96 

97 if not template.template_repository: 

98 logger.error("template-repository is not configured in template.yml") 

99 raise RuntimeError("template-repository is required") # noqa: TRY003 

100 

101 if not template.templates and not template.include and not template.profiles: 

102 logger.error("No templates, profiles, or include paths found in template.yml") 

103 logger.error("Add 'templates', 'profiles', or 'include' to template.yml") 

104 raise RuntimeError("No templates, profile, or include paths found in template.yml") # noqa: TRY003 

105 

106 _log_list("Profiles", template.profiles) 

107 _log_list("Templates", template.templates) 

108 _log_list("Include paths", template.include) 

109 _log_list("Exclude paths", template.exclude) 

110 

111 return template 

112 

113 

114def _validate_clone_config(template: RhizaTemplate) -> None: 

115 """Validate that *template* carries enough configuration to clone. 

116 

117 Args: 

118 template: The template configuration. 

119 

120 Raises: 

121 ValueError: If ``template_repository`` is not set, or no templates, 

122 profile, or include paths are configured. 

123 """ 

124 if not template.template_repository: 

125 raise ValueError("template_repository is not configured in template.yml") # noqa: TRY003 

126 if not template.templates and not template.include and not template.profiles: 

127 raise ValueError("No templates, profile, or include paths found in template.yml") # noqa: TRY003 

128 

129 

130def _raise_unknown_profile(profile_name: str, bundles_path: str, available_profiles: Mapping[str, object]) -> None: 

131 """Raise a ``ValueError`` describing a profile that is not defined. 

132 

133 Args: 

134 profile_name: The profile that could not be found. 

135 bundles_path: Path to the bundle definitions file (used in the message). 

136 available_profiles: Mapping of the profiles that *are* defined. 

137 

138 Raises: 

139 ValueError: Always — lists the available profiles when any exist. 

140 """ 

141 sorted_available = sorted(available_profiles) 

142 if sorted_available: 

143 available_text = ", ".join(sorted_available) 

144 raise ValueError( # noqa: TRY003 

145 f"Profile '{profile_name}' was not found in {bundles_path}. Available profiles: {available_text}" 

146 ) 

147 raise ValueError( # noqa: TRY003 

148 f"Profile '{profile_name}' was not found in {bundles_path}. No profiles are defined." 

149 ) 

150 

151 

152def _resolve_bundle_names(template: RhizaTemplate, bundles: "RhizaBundles", bundles_path: str) -> list[str]: 

153 """Resolve configured profiles and templates to a deduplicated bundle-name list. 

154 

155 Profiles are expanded to their constituent bundle names (preserving order and 

156 dropping duplicates) and then merged with any explicitly configured 

157 ``templates``. When no profiles are configured, the explicit ``templates`` 

158 list is returned unchanged. 

159 

160 Args: 

161 template: The template configuration. 

162 bundles: Parsed bundle definitions from the template repository. 

163 bundles_path: Path to the bundle definitions file (used in error messages). 

164 

165 Returns: 

166 The ordered, deduplicated list of bundle names to check out. 

167 

168 Raises: 

169 ValueError: If a configured profile is not defined in *bundles*. 

170 """ 

171 if not template.profiles: 

172 return template.templates 

173 

174 available_profiles = bundles.profiles or {} 

175 profile_bundle_names: list[str] = [] 

176 for profile_name in template.profiles: 

177 if profile_name not in available_profiles: 

178 _raise_unknown_profile(profile_name, bundles_path, available_profiles) 

179 for bundle in available_profiles[profile_name].bundles: 

180 if bundle not in profile_bundle_names: 

181 profile_bundle_names.append(bundle) 

182 return list(dict.fromkeys(profile_bundle_names + template.templates)) 

183 

184 

185def _clone_template( 

186 template: RhizaTemplate, 

187 git_ctx: GitContext, 

188 branch: str = "main", 

189) -> tuple[Path, str, list[str], dict[str, str]]: 

190 """Clone the upstream template repository and resolve include paths. 

191 

192 Clones the template repository using sparse checkout. When 

193 ``templates`` are configured the corresponding bundle names are resolved 

194 to file paths via :meth:`~rhiza.models.RhizaTemplate.resolve_include_paths`. 

195 

196 Args: 

197 template: The template configuration. 

198 git_ctx: Git context. 

199 branch: Default branch to use when ``template_branch`` is not set 

200 on the template. 

201 

202 Returns: 

203 Tuple of ``(upstream_dir, upstream_sha, resolved_include, path_map)`` where 

204 *upstream_dir* is a temporary directory containing the cloned repository 

205 tree and *path_map* maps source paths to destination paths for remapped 

206 entries. The caller is responsible for removing *upstream_dir* when done. 

207 

208 Raises: 

209 ValueError: If ``template_repository`` is not set, the host is 

210 unsupported, or no include paths / templates are configured. 

211 subprocess.CalledProcessError: If a git operation fails. 

212 """ 

213 from rhiza.models.bundle import RhizaBundles 

214 

215 _validate_clone_config(template) 

216 

217 rhiza_branch = template.template_branch or branch 

218 include_paths = list(template.include) 

219 upstream_dir = Path(tempfile.mkdtemp()) 

220 

221 if template.profiles or template.templates: 

222 # Checkout the bundle definitions file from template_repository @ template_branch 

223 bundles_path = template.template_bundles_path 

224 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, [bundles_path]) 

225 

226 # Load bundle definitions 

227 bundles = RhizaBundles.from_yaml(upstream_dir / bundles_path) 

228 

229 # Resolve profiles → bundle names, then merge with explicit templates list 

230 all_bundle_names = _resolve_bundle_names(template, bundles, bundles_path) 

231 

232 resolved_paths = bundles.resolve_to_paths(all_bundle_names) 

233 path_map = bundles.resolve_to_path_map(all_bundle_names) 

234 # Merge resolved bundle paths with any explicit include: paths (hybrid mode) 

235 merged_paths = list(dict.fromkeys(resolved_paths + include_paths)) 

236 git_ctx.update_sparse_checkout(upstream_dir, merged_paths) 

237 include_paths = merged_paths 

238 else: 

239 path_map = {} 

240 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, include_paths) 

241 

242 upstream_sha = git_ctx.get_head_sha(upstream_dir) 

243 logger.info(f"Upstream HEAD: {upstream_sha[:12]}") 

244 

245 return upstream_dir, upstream_sha, include_paths, path_map 

246 

247 

248def sync( 

249 target: Path, 

250 branch: str, 

251 target_branch: str | None, 

252 strategy: str, 

253 template_file: Path | None = None, 

254 lock_file: Path | None = None, 

255) -> None: 

256 """Sync Rhiza templates using cruft-style diff/merge. 

257 

258 Uses diff utilities to compute the diff between the base 

259 (last-synced) and upstream (latest) template snapshots, then applies 

260 the diff to the project using ``git apply -3`` for a 3-way merge. 

261 

262 Args: 

263 target: Path to the target repository. 

264 branch: The Rhiza template branch to use. 

265 target_branch: Optional branch name to create/checkout in the target. 

266 strategy: Sync strategy -- ``"merge"`` for 3-way merge, 

267 or ``"diff"`` for dry-run showing what would change. 

268 template_file: Optional explicit path to the ``template.yml`` file. 

269 When ``None`` the default ``<target>/.rhiza/template.yml`` is used. 

270 lock_file: Optional explicit path for the output lock file. When 

271 ``None`` the default ``<target>/.rhiza/template.lock`` is used. 

272 """ 

273 target = target.resolve() 

274 logger.info(f"Target repository: {target}") 

275 logger.info(f"Rhiza branch: {branch}") 

276 logger.info(f"Sync strategy: {strategy}") 

277 

278 git_ctx = GitContext.default() 

279 

280 git_ctx.assert_status_clean(target) 

281 git_ctx.handle_target_branch(target, target_branch) 

282 

283 template = _load_template_from_project(target, template_file=template_file) 

284 

285 # Capture original include before resolving bundles (templates: mode) 

286 original_include = list(template.include) 

287 

288 logger.info(f"Cloning {template.template_repository}@{template.template_branch} (upstream)") 

289 upstream_dir, upstream_sha, resolved_include, path_map = _clone_template(template, git_ctx, branch=branch) 

290 

291 # Synchronizes target with upstream template snapshot transactionally; cleans up resources 

292 try: 

293 lock_path = lock_file if lock_file is not None else target / ".rhiza" / "template.lock" 

294 base_sha = TemplateLock.from_yaml(lock_path).config["sha"] if lock_path.exists() else None 

295 

296 upstream_snapshot = Path(tempfile.mkdtemp()) 

297 try: 

298 excludes = _excluded_set(upstream_dir, template.exclude) 

299 template_files = _prepare_snapshot( 

300 upstream_dir, resolved_include, excludes, upstream_snapshot, path_map=path_map 

301 ) 

302 logger.info(f"Upstream: {len(template_files)} file(s) to consider") 

303 lock = TemplateLock( 

304 sha=upstream_sha, 

305 repo=template.template_repository, 

306 host=template.template_host, 

307 ref=template.template_branch, 

308 include=original_include, 

309 exclude=template.exclude, 

310 templates=template.templates, 

311 profiles=template.profiles, 

312 files=[str(p) for p in template_files], 

313 synced_at=datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), 

314 strategy=strategy, 

315 ) 

316 

317 # Build a resolved template view for merge operations (bundles → concrete paths) 

318 resolved_template = dataclasses.replace(template, include=resolved_include, templates=[]) 

319 

320 if strategy == "diff": 

321 git_ctx.sync_diff( 

322 target=target, 

323 upstream_snapshot=upstream_snapshot, 

324 ) 

325 else: 

326 clean = git_ctx.sync_merge( 

327 target=target, 

328 upstream_snapshot=upstream_snapshot, 

329 upstream_sha=upstream_sha, 

330 base_sha=base_sha, 

331 template_files=template_files, 

332 template=resolved_template, 

333 excludes=excludes, 

334 lock=lock, 

335 lock_file=lock_file, 

336 path_map=path_map, 

337 ) 

338 if not clean: 

339 logger.error("Sync completed with conflicts — see the file list above for details") 

340 logger.error( 

341 "Resolve all conflicts locally (remove *.rej files and conflict markers),\n" 

342 " then commit the result." 

343 ) 

344 msg = "Sync completed with merge conflicts" 

345 raise RuntimeError(msg) 

346 finally: 

347 if upstream_snapshot.exists(): 

348 shutil.rmtree(upstream_snapshot) 

349 finally: 

350 if upstream_dir.exists(): 

351 shutil.rmtree(upstream_dir)