Coverage for src/rhiza_tools/commands/rollback/__init__.py: 100%

124 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-30 13:37 +0000

1"""Command to rollback a release and/or version bump. 

2 

3This module implements rollback functionality that safely reverses release 

4and bump operations. It can delete local and remote tags, revert bump commits, 

5and restore the project to a previous version state. 

6 

7Example: 

8 Rollback the most recent release:: 

9 

10 from rhiza_tools.commands.rollback import rollback_command 

11 rollback_command() 

12 

13 Rollback a specific tag:: 

14 

15 rollback_command(tag="v1.2.3") 

16 

17 Dry run to preview rollback:: 

18 

19 rollback_command(dry_run=True) 

20""" 

21 

22from __future__ import annotations 

23 

24from dataclasses import dataclass 

25 

26import questionary as qs 

27import typer 

28from loguru import logger 

29 

30from rhiza_tools import console 

31from rhiza_tools.commands._shared import ( 

32 COOL_STYLE, 

33 NON_INTERACTIVE_ERRORS, 

34 run_git_command, 

35 validate_pyproject_exists, 

36) 

37from rhiza_tools.commands.release import check_tag_exists 

38 

39# Git tag/commit plumbing lives in rollback/git.py; re-exported here so callers and 

40# existing tests keep importing ``rollback.<helper>``. 

41from rhiza_tools.commands.rollback.git import ( 

42 _delete_local_tag, 

43 _delete_remote_tag, 

44 _get_previous_version_from_tags, 

45 _get_tag_commit, 

46 _get_tag_details, 

47 _is_bump_commit, 

48 _revert_bump_commit, 

49) 

50 

51# Interactive UI and display helpers live in rollback/io.py; re-exported here so 

52# callers and existing tests keep importing ``rollback.<helper>``. 

53from rhiza_tools.commands.rollback.io import ( 

54 _confirm_rollback as _confirm_rollback, 

55) 

56from rhiza_tools.commands.rollback.io import ( 

57 _push_revert as _push_revert, 

58) 

59from rhiza_tools.commands.rollback.io import ( 

60 _select_tag_interactively as _select_tag_interactively, 

61) 

62from rhiza_tools.commands.rollback.io import ( 

63 _show_rollback_plan as _show_rollback_plan, 

64) 

65 

66 

67@dataclass 

68class RollbackOptions: 

69 """Configuration options for the rollback command. 

70 

71 Attributes: 

72 tag: The tag to rollback (e.g., "v1.2.3"). None for interactive selection. 

73 revert_bump: If True, also revert the version bump commit. 

74 dry_run: If True, show what would change without actually changing anything. 

75 non_interactive: If True, skip all confirmation prompts. 

76 """ 

77 

78 tag: str | None = None 

79 revert_bump: bool = False 

80 dry_run: bool = False 

81 non_interactive: bool = False 

82 

83 

84def _get_recent_tags(limit: int = 10) -> list[str]: 

85 """Get recent version tags sorted by version descending. 

86 

87 Args: 

88 limit: Maximum number of tags to return. 

89 

90 Returns: 

91 List of tag names (e.g., ["v1.2.3", "v1.2.2", ...]). 

92 """ 

93 result = run_git_command( 

94 ["git", "tag", "--sort=-version:refname", "-l", "v*"], 

95 check=False, 

96 ) 

97 if result.returncode != 0 or not result.stdout.strip(): 

98 return [] 

99 

100 tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()] 

101 return tags[:limit] 

102 

103 

104def _validate_rollback_preconditions(tag: str) -> tuple[bool, bool]: 

105 """Validate that the tag exists somewhere before attempting rollback. 

106 

107 Args: 

108 tag: The tag to validate. 

109 

110 Returns: 

111 Tuple of (exists_locally, exists_remotely). 

112 

113 Raises: 

114 typer.Exit: If the tag doesn't exist anywhere. 

115 """ 

116 exists_locally, exists_remotely = check_tag_exists(tag) 

117 

118 if not exists_locally and not exists_remotely: 

119 console.error(f"Tag '{tag}' does not exist locally or on remote.") 

120 console.error("Nothing to rollback.") 

121 raise typer.Exit(code=1) 

122 

123 return exists_locally, exists_remotely 

124 

125 

126def _resolve_tag(options: RollbackOptions) -> str: 

127 """Determine which tag to rollback from options or interactively. 

128 

129 Args: 

130 options: Rollback configuration options. 

131 

132 Returns: 

133 The resolved tag name with 'v' prefix. 

134 

135 Raises: 

136 typer.Exit: If no tags are found in non-interactive mode. 

137 """ 

138 tag = options.tag 

139 if not tag: 

140 if options.non_interactive: 

141 recent_tags = _get_recent_tags(limit=1) 

142 if not recent_tags: 

143 console.error("No version tags found in the repository.") 

144 raise typer.Exit(code=1) 

145 tag = recent_tags[0] 

146 console.info(f"Non-interactive mode: rolling back most recent tag: {tag}") 

147 else: 

148 recent_tags = _get_recent_tags() 

149 tag = _select_tag_interactively(recent_tags) 

150 

151 if not tag.startswith("v"): 

152 tag = f"v{tag}" 

153 

154 return tag 

155 

156 

157def _should_revert_bump(options: RollbackOptions, exists_locally: bool, is_bump: bool) -> bool: 

158 """Determine whether the bump commit should be reverted. 

159 

160 If ``--revert-bump`` was passed, returns True immediately. Otherwise, 

161 prompts the user interactively when the tagged commit looks like a bump. 

162 

163 Args: 

164 options: Rollback configuration options. 

165 exists_locally: Whether the tag exists locally. 

166 is_bump: Whether the tagged commit is a bump commit. 

167 

168 Returns: 

169 True if the bump commit should be reverted. 

170 """ 

171 if options.revert_bump: 

172 return True 

173 

174 if options.non_interactive or options.dry_run or not exists_locally or not is_bump: 

175 return False 

176 

177 try: 

178 return bool( 

179 qs.confirm( 

180 "The tagged commit appears to be a version bump. Revert it too?", 

181 default=True, 

182 style=COOL_STYLE, 

183 ).ask() 

184 ) 

185 except NON_INTERACTIVE_ERRORS: 

186 logger.debug("Running in non-interactive environment") 

187 return False 

188 

189 

190def _delete_rollback_tags( 

191 tag: str, 

192 exists_locally: bool, 

193 exists_remotely: bool, 

194 dry_run: bool, 

195) -> bool: 

196 """Delete the remote then local tag for a rollback. 

197 

198 The remote tag is deleted first to stop any in-progress release. A failure 

199 to delete the remote tag aborts the rollback in non-dry-run mode. 

200 

201 Args: 

202 tag: The tag to delete. 

203 exists_locally: Whether the tag exists locally. 

204 exists_remotely: Whether the tag exists on remote. 

205 dry_run: If True, only simulate changes. 

206 

207 Returns: 

208 True if all attempted deletions succeeded. 

209 

210 Raises: 

211 typer.Exit: If the remote tag deletion fails (non-dry-run). 

212 """ 

213 success = True 

214 

215 # Delete remote tag first to stop any in-progress release 

216 if exists_remotely and not _delete_remote_tag(tag, dry_run): 

217 success = False 

218 if not dry_run: 

219 console.error("Failed to delete remote tag. Aborting remaining steps.") 

220 console.error("You can retry or manually delete with:") 

221 console.error(f" git push origin :refs/tags/{tag}") 

222 raise typer.Exit(code=1) 

223 

224 # Delete local tag 

225 if exists_locally and not _delete_local_tag(tag, dry_run): 

226 success = False 

227 if not dry_run: 

228 console.warning(f"Failed to delete local tag. Delete manually: git tag -d {tag}") 

229 

230 return success 

231 

232 

233def _execute_rollback( 

234 tag: str, 

235 exists_locally: bool, 

236 exists_remotely: bool, 

237 revert_bump: bool, 

238 is_bump: bool, 

239 dry_run: bool, 

240 non_interactive: bool, 

241) -> bool: 

242 """Execute the rollback steps: delete tags, revert bump, push. 

243 

244 Args: 

245 tag: The tag to rollback. 

246 exists_locally: Whether the tag exists locally. 

247 exists_remotely: Whether the tag exists on remote. 

248 revert_bump: Whether to revert the bump commit. 

249 is_bump: Whether the tagged commit is a bump commit. 

250 dry_run: If True, only simulate changes. 

251 non_interactive: If True, skip confirmation prompts. 

252 

253 Returns: 

254 True if all steps succeeded. 

255 

256 Raises: 

257 typer.Exit: If the remote tag deletion fails (non-dry-run). 

258 """ 

259 # Get commit hash BEFORE deleting tags (needed for revert) 

260 tag_commit = None 

261 if revert_bump and is_bump and exists_locally: 

262 tag_commit = _get_tag_commit(tag) 

263 if not tag_commit: 

264 console.error(f"Could not find commit for tag: {tag}") 

265 console.error("Skipping bump revert but proceeding with tag deletion.") 

266 revert_bump = False 

267 

268 success = _delete_rollback_tags(tag, exists_locally, exists_remotely, dry_run) 

269 

270 # Revert bump commit and push (if requested) 

271 if revert_bump and is_bump and tag_commit: 

272 if not _revert_bump_commit(tag_commit, dry_run): 

273 success = False 

274 if not dry_run: 

275 console.warning("Bump revert failed. Tags were still deleted.") 

276 console.warning("You may need to manually revert the bump commit.") 

277 elif not _push_revert(dry_run, non_interactive): 

278 success = False 

279 

280 return success 

281 

282 

283def _print_rollback_summary(dry_run: bool, success: bool, previous_tag: str | None) -> None: 

284 """Print a summary after rollback execution. 

285 

286 Args: 

287 dry_run: Whether this was a dry run. 

288 success: Whether all rollback steps succeeded. 

289 previous_tag: The previous version tag, if any. 

290 """ 

291 if dry_run: 

292 console.info("\n[DRY-RUN] Rollback preview complete (no changes made)") 

293 return 

294 

295 if not success: 

296 console.warning("\nRollback completed with warnings. Review the output above.") 

297 return 

298 

299 success_msg = typer.style("✓", fg=typer.colors.GREEN, bold=True) 

300 console.success(f"\n{success_msg} Rollback completed successfully!") 

301 

302 if previous_tag: 

303 console.info(f"Previous version was: {previous_tag}") 

304 console.info("To re-release at the previous version, run:") 

305 console.info(" rhiza-tools release") 

306 console.info("To bump to a new version instead:") 

307 console.info(" rhiza-tools bump") 

308 else: 

309 console.info("No previous version tag found.") 

310 console.info("To set a new version:") 

311 console.info(" rhiza-tools bump <version>") 

312 

313 

314def rollback_command(options: RollbackOptions) -> None: 

315 """Rollback a release and/or version bump. 

316 

317 This command safely reverses release and bump operations by: 

318 

319 1. Deleting the release tag from remote (stops/prevents the release workflow) 

320 2. Deleting the release tag locally 

321 3. Optionally reverting the version bump commit (with ``--revert-bump``) 

322 4. Optionally pushing the revert commit to remote 

323 

324 The command uses ``git revert`` rather than ``git reset`` to create a new 

325 revert commit, making it safe even when changes have been pushed to remote. 

326 

327 Args: 

328 options: Configuration options for the rollback. 

329 

330 Raises: 

331 typer.Exit: If the tag doesn't exist, pyproject.toml is missing, 

332 or any git operations fail. 

333 

334 Example: 

335 Rollback the most recent release:: 

336 

337 rollback_command(RollbackOptions()) 

338 

339 Preview rollback:: 

340 

341 rollback_command(RollbackOptions(dry_run=True)) 

342 

343 Rollback a specific tag with bump revert:: 

344 

345 rollback_command(RollbackOptions(tag="v1.2.3", revert_bump=True)) 

346 

347 Non-interactive rollback:: 

348 

349 rollback_command(RollbackOptions( 

350 tag="v1.2.3", 

351 revert_bump=True, 

352 non_interactive=True, 

353 )) 

354 """ 

355 validate_pyproject_exists() 

356 

357 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"]) 

358 current_branch = result.stdout.strip() 

359 console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}") 

360 

361 tag = _resolve_tag(options) 

362 exists_locally, exists_remotely = _validate_rollback_preconditions(tag) 

363 

364 tag_details = _get_tag_details(tag) if exists_locally else {} 

365 is_bump = _is_bump_commit(tag) if exists_locally else False 

366 previous_tag = _get_previous_version_from_tags(tag) 

367 revert_bump = _should_revert_bump(options, exists_locally, is_bump) 

368 

369 _show_rollback_plan(tag, exists_locally, exists_remotely, revert_bump, is_bump, previous_tag, tag_details) 

370 

371 if not options.dry_run and not _confirm_rollback(options.non_interactive): 

372 console.info("Rollback cancelled by user.") 

373 raise typer.Exit(code=0) 

374 

375 success = _execute_rollback( 

376 tag, exists_locally, exists_remotely, revert_bump, is_bump, options.dry_run, options.non_interactive 

377 ) 

378 _print_rollback_summary(options.dry_run, success, previous_tag)