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
« 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.
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.
7Example:
8 Rollback the most recent release::
10 from rhiza_tools.commands.rollback import rollback_command
11 rollback_command()
13 Rollback a specific tag::
15 rollback_command(tag="v1.2.3")
17 Dry run to preview rollback::
19 rollback_command(dry_run=True)
20"""
22from __future__ import annotations
24from dataclasses import dataclass
26import questionary as qs
27import typer
28from loguru import logger
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
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)
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)
67@dataclass
68class RollbackOptions:
69 """Configuration options for the rollback command.
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 """
78 tag: str | None = None
79 revert_bump: bool = False
80 dry_run: bool = False
81 non_interactive: bool = False
84def _get_recent_tags(limit: int = 10) -> list[str]:
85 """Get recent version tags sorted by version descending.
87 Args:
88 limit: Maximum number of tags to return.
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 []
100 tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
101 return tags[:limit]
104def _validate_rollback_preconditions(tag: str) -> tuple[bool, bool]:
105 """Validate that the tag exists somewhere before attempting rollback.
107 Args:
108 tag: The tag to validate.
110 Returns:
111 Tuple of (exists_locally, exists_remotely).
113 Raises:
114 typer.Exit: If the tag doesn't exist anywhere.
115 """
116 exists_locally, exists_remotely = check_tag_exists(tag)
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)
123 return exists_locally, exists_remotely
126def _resolve_tag(options: RollbackOptions) -> str:
127 """Determine which tag to rollback from options or interactively.
129 Args:
130 options: Rollback configuration options.
132 Returns:
133 The resolved tag name with 'v' prefix.
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)
151 if not tag.startswith("v"):
152 tag = f"v{tag}"
154 return tag
157def _should_revert_bump(options: RollbackOptions, exists_locally: bool, is_bump: bool) -> bool:
158 """Determine whether the bump commit should be reverted.
160 If ``--revert-bump`` was passed, returns True immediately. Otherwise,
161 prompts the user interactively when the tagged commit looks like a bump.
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.
168 Returns:
169 True if the bump commit should be reverted.
170 """
171 if options.revert_bump:
172 return True
174 if options.non_interactive or options.dry_run or not exists_locally or not is_bump:
175 return False
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
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.
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.
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.
207 Returns:
208 True if all attempted deletions succeeded.
210 Raises:
211 typer.Exit: If the remote tag deletion fails (non-dry-run).
212 """
213 success = True
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)
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}")
230 return success
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.
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.
253 Returns:
254 True if all steps succeeded.
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
268 success = _delete_rollback_tags(tag, exists_locally, exists_remotely, dry_run)
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
280 return success
283def _print_rollback_summary(dry_run: bool, success: bool, previous_tag: str | None) -> None:
284 """Print a summary after rollback execution.
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
295 if not success:
296 console.warning("\nRollback completed with warnings. Review the output above.")
297 return
299 success_msg = typer.style("✓", fg=typer.colors.GREEN, bold=True)
300 console.success(f"\n{success_msg} Rollback completed successfully!")
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>")
314def rollback_command(options: RollbackOptions) -> None:
315 """Rollback a release and/or version bump.
317 This command safely reverses release and bump operations by:
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
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.
327 Args:
328 options: Configuration options for the rollback.
330 Raises:
331 typer.Exit: If the tag doesn't exist, pyproject.toml is missing,
332 or any git operations fail.
334 Example:
335 Rollback the most recent release::
337 rollback_command(RollbackOptions())
339 Preview rollback::
341 rollback_command(RollbackOptions(dry_run=True))
343 Rollback a specific tag with bump revert::
345 rollback_command(RollbackOptions(tag="v1.2.3", revert_bump=True))
347 Non-interactive rollback::
349 rollback_command(RollbackOptions(
350 tag="v1.2.3",
351 revert_bump=True,
352 non_interactive=True,
353 ))
354 """
355 validate_pyproject_exists()
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)}")
361 tag = _resolve_tag(options)
362 exists_locally, exists_remotely = _validate_rollback_preconditions(tag)
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)
369 _show_rollback_plan(tag, exists_locally, exists_remotely, revert_bump, is_bump, previous_tag, tag_details)
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)
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)