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

117 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-15 18:22 +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 pathlib import Path 

25 

26from loguru import logger 

27 

28from rhiza.models import GitContext, RhizaTemplate, TemplateLock 

29from rhiza.models._git_utils import _excluded_set, _prepare_snapshot 

30 

31__all__ = ["sync"] 

32 

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

34 

35 

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

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

38 

39 Args: 

40 header: Label printed before the list. 

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

42 """ 

43 if items: 

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

45 for item in items: 

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

47 

48 

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

50 """Validate and load a :class:`RhizaTemplate` from a project directory. 

51 

52 Validates the project's ``template.yml`` via :func:`~rhiza.commands.validate.validate`, 

53 then loads the configuration with :meth:`~rhiza.models.RhizaTemplate.from_yaml` and 

54 checks that the required fields are present. 

55 

56 Args: 

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

58 ``.rhiza/template.yml``). 

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

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

61 

62 Returns: 

63 The loaded and validated :class:`RhizaTemplate`. 

64 

65 Raises: 

66 RuntimeError: If validation fails or required fields are missing. 

67 """ 

68 from rhiza.commands.validate import validate 

69 

70 valid = validate(target, template_file=template_file) 

71 if not valid: 

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

73 logger.error("Please fix validation errors and try again") 

74 raise RuntimeError("Rhiza template validation failed") # noqa: TRY003 

75 

76 if template_file is None: 

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

78 template = RhizaTemplate.from_yaml(template_file) 

79 

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

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

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

83 if template.template_bundles_path == _DEFAULT_BUNDLES_PATH: 

84 try: 

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

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

87 if derived != _DEFAULT_BUNDLES_PATH: 

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

89 except ValueError: 

90 pass # template_file is outside target root; keep default 

91 

92 if not template.template_repository: 

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

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

95 

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

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

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

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

100 

101 _log_list("Profiles", template.profiles) 

102 _log_list("Templates", template.templates) 

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

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

105 

106 return template 

107 

108 

109def _clone_template( 

110 template: RhizaTemplate, 

111 git_ctx: GitContext, 

112 branch: str = "main", 

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

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

115 

116 Clones the template repository using sparse checkout. When 

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

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

119 

120 Args: 

121 template: The template configuration. 

122 git_ctx: Git context. 

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

124 on the template. 

125 

126 Returns: 

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

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

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

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

131 

132 Raises: 

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

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

135 subprocess.CalledProcessError: If a git operation fails. 

136 """ 

137 from rhiza.models.bundle import RhizaBundles 

138 

139 if not template.template_repository: 

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

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

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

143 

144 rhiza_branch = template.template_branch or branch 

145 include_paths = list(template.include) 

146 upstream_dir = Path(tempfile.mkdtemp()) 

147 

148 if template.profiles or template.templates: 

149 # Checkout the bundle definitions file from template_repository @ template_branch 

150 bundles_path = template.template_bundles_path 

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

152 

153 # Load bundle definitions 

154 bundles = RhizaBundles.from_yaml(upstream_dir / bundles_path) 

155 

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

157 if template.profiles: 

158 available_profiles = bundles.profiles or {} 

159 profile_bundle_names: list[str] = [] 

160 for profile_name in template.profiles: 

161 if profile_name not in available_profiles: 

162 sorted_available = sorted(available_profiles) 

163 if sorted_available: 

164 available_text = ", ".join(sorted_available) 

165 raise ValueError( # noqa: TRY003 

166 f"Profile '{profile_name}' was not found in {bundles_path}. " 

167 f"Available profiles: {available_text}" 

168 ) 

169 raise ValueError( # noqa: TRY003 

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

171 ) 

172 for bundle in available_profiles[profile_name].bundles: 

173 if bundle not in profile_bundle_names: 

174 profile_bundle_names.append(bundle) 

175 all_bundle_names = list(dict.fromkeys(profile_bundle_names + template.templates)) 

176 else: 

177 all_bundle_names = template.templates 

178 

179 resolved_paths = bundles.resolve_to_paths(all_bundle_names) 

180 path_map = bundles.resolve_to_path_map(all_bundle_names) 

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

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

183 git_ctx.update_sparse_checkout(upstream_dir, merged_paths) 

184 include_paths = merged_paths 

185 else: 

186 path_map = {} 

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

188 

189 upstream_sha = git_ctx.get_head_sha(upstream_dir) 

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

191 

192 return upstream_dir, upstream_sha, include_paths, path_map 

193 

194 

195def sync( 

196 target: Path, 

197 branch: str, 

198 target_branch: str | None, 

199 strategy: str, 

200 template_file: Path | None = None, 

201 lock_file: Path | None = None, 

202) -> None: 

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

204 

205 Uses diff utilities to compute the diff between the base 

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

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

208 

209 Args: 

210 target: Path to the target repository. 

211 branch: The Rhiza template branch to use. 

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

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

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

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

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

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

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

219 """ 

220 target = target.resolve() 

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

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

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

224 

225 git_ctx = GitContext.default() 

226 

227 git_ctx.assert_status_clean(target) 

228 git_ctx.handle_target_branch(target, target_branch) 

229 

230 template = _load_template_from_project(target, template_file=template_file) 

231 

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

233 original_include = list(template.include) 

234 

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

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

237 

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

239 try: 

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

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

242 

243 upstream_snapshot = Path(tempfile.mkdtemp()) 

244 try: 

245 excludes = _excluded_set(upstream_dir, template.exclude) 

246 materialized = _prepare_snapshot( 

247 upstream_dir, resolved_include, excludes, upstream_snapshot, path_map=path_map 

248 ) 

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

250 lock = TemplateLock( 

251 sha=upstream_sha, 

252 repo=template.template_repository, 

253 host=template.template_host, 

254 ref=template.template_branch, 

255 include=original_include, 

256 exclude=template.exclude, 

257 templates=template.templates, 

258 profiles=template.profiles, 

259 files=[str(p) for p in materialized], 

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

261 strategy=strategy, 

262 ) 

263 

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

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

266 

267 if strategy == "diff": 

268 git_ctx.sync_diff( 

269 target=target, 

270 upstream_snapshot=upstream_snapshot, 

271 ) 

272 else: 

273 clean = git_ctx.sync_merge( 

274 target=target, 

275 upstream_snapshot=upstream_snapshot, 

276 upstream_sha=upstream_sha, 

277 base_sha=base_sha, 

278 materialized=materialized, 

279 template=resolved_template, 

280 excludes=excludes, 

281 lock=lock, 

282 lock_file=lock_file, 

283 path_map=path_map, 

284 ) 

285 if not clean: 

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

287 logger.error( 

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

289 " then commit the result." 

290 ) 

291 msg = "Sync completed with merge conflicts" 

292 raise RuntimeError(msg) 

293 finally: 

294 if upstream_snapshot.exists(): 

295 shutil.rmtree(upstream_snapshot) 

296 finally: 

297 if upstream_dir.exists(): 

298 shutil.rmtree(upstream_dir)