Coverage for src/rhiza_tools/commands/rollback/git.py: 100%
77 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"""Git tag and commit plumbing for the rollback command.
3These helpers wrap the read-only git queries (tag commit, details, history) and
4the tag/commit mutations (delete local/remote tag, revert) that rollback needs.
5They contain no interactive prompts, so they live apart from the orchestration
6and UI in ``rollback.py``, which re-exports them for its callers and tests.
7"""
9from __future__ import annotations
11from rhiza_tools import console
12from rhiza_tools.commands._shared import run_git_command
14# Number of fields in the `%H|%ci|%s` git-show format (commit hash, date, subject).
15_TAG_DETAIL_FIELDS = 3
18def _get_tag_commit(tag: str) -> str | None:
19 """Get the commit hash that a tag points to.
21 Args:
22 tag: The tag name.
24 Returns:
25 The commit hash, or None if the tag doesn't exist locally.
26 """
27 result = run_git_command(["git", "rev-list", "-n", "1", tag], check=False)
28 if result.returncode != 0:
29 return None
30 return result.stdout.strip()
33def _get_tag_details(tag: str) -> dict[str, str]:
34 """Get details about a tag.
36 Args:
37 tag: The tag name.
39 Returns:
40 Dictionary with commit hash, date, and message.
41 """
42 details: dict[str, str] = {}
43 result = run_git_command(
44 ["git", "show", "-s", "--format=%H|%ci|%s", tag],
45 check=False,
46 )
47 if result.returncode == 0 and result.stdout.strip():
48 parts = result.stdout.strip().split("|")
49 if len(parts) == _TAG_DETAIL_FIELDS:
50 details["hash"] = parts[0]
51 details["date"] = parts[1]
52 details["message"] = parts[2]
53 return details
56def _is_bump_commit(tag: str) -> bool:
57 """Check if the commit the tag points to looks like a bump commit.
59 Bump commits typically have messages like "Bump version: X.Y.Z → A.B.C"
60 or contain version-related keywords.
62 Args:
63 tag: The tag name.
65 Returns:
66 True if the tag's commit appears to be a bump commit.
67 """
68 result = run_git_command(
69 ["git", "log", "-1", "--format=%s", tag],
70 check=False,
71 )
72 if result.returncode != 0:
73 return False
75 message = result.stdout.strip().lower()
76 bump_keywords = ["bump version", "bump:", "version bump", "release version", "chore: bump"]
77 return any(keyword in message for keyword in bump_keywords)
80def _get_previous_version_from_tags(current_tag: str) -> str | None:
81 """Find the previous version tag before the given tag.
83 Args:
84 current_tag: The current tag being rolled back.
86 Returns:
87 The previous tag name, or None if no previous tag exists.
88 """
89 result = run_git_command(
90 ["git", "tag", "--sort=-version:refname", "-l", "v*"],
91 check=False,
92 )
93 if result.returncode != 0 or not result.stdout.strip():
94 return None
96 tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
98 try:
99 idx = tags.index(current_tag)
100 if idx + 1 < len(tags):
101 return tags[idx + 1]
102 except ValueError:
103 # current_tag is not present in the version-sorted tag list.
104 return None
106 return None
109def _delete_local_tag(tag: str, dry_run: bool) -> bool:
110 """Delete a tag from the local repository.
112 Args:
113 tag: The tag name to delete.
114 dry_run: If True, only simulate deletion.
116 Returns:
117 True if deletion succeeded (or would succeed in dry-run).
118 """
119 if dry_run:
120 console.info(f"[DRY-RUN] Would delete local tag: {tag}")
121 return True
123 result = run_git_command(["git", "tag", "-d", tag], check=False)
124 if result.returncode == 0:
125 console.success(f"Deleted local tag: {tag}")
126 return True
127 else:
128 console.error(f"Failed to delete local tag: {tag}")
129 console.error(f"Error: {result.stderr}")
130 return False
133def _delete_remote_tag(tag: str, dry_run: bool) -> bool:
134 """Delete a tag from the remote repository.
136 Args:
137 tag: The tag name to delete.
138 dry_run: If True, only simulate deletion.
140 Returns:
141 True if deletion succeeded (or would succeed in dry-run).
142 """
143 if dry_run:
144 console.info(f"[DRY-RUN] Would delete remote tag: {tag}")
145 return True
147 console.info(f"Deleting remote tag: {tag}...")
148 result = run_git_command(
149 ["git", "push", "origin", f":refs/tags/{tag}"],
150 check=False,
151 )
152 if result.returncode == 0:
153 console.success(f"Deleted remote tag: {tag}")
154 return True
155 else:
156 console.error(f"Failed to delete remote tag: {tag}")
157 console.error(f"Error: {result.stderr}")
158 return False
161def _revert_bump_commit(commit_hash: str, dry_run: bool) -> bool:
162 """Revert the version bump commit.
164 Creates a new revert commit rather than rewriting history, making
165 this safe even when the commit has been pushed to remote.
167 Args:
168 commit_hash: The commit hash to revert.
169 dry_run: If True, only simulate the revert.
171 Returns:
172 True if revert succeeded (or would succeed in dry-run).
173 """
174 if dry_run:
175 result = run_git_command(
176 ["git", "log", "-1", "--format=%s", commit_hash],
177 check=False,
178 )
179 commit_msg = result.stdout.strip() if result.returncode == 0 else "unknown"
180 console.info(f"[DRY-RUN] Would revert commit {commit_hash[:8]}: {commit_msg}")
181 return True
183 console.info(f"Reverting bump commit {commit_hash[:8]}...")
184 result = run_git_command(
185 ["git", "revert", "--no-edit", commit_hash],
186 check=False,
187 )
188 if result.returncode == 0:
189 console.success(f"Reverted bump commit: {commit_hash[:8]}")
190 return True
191 else:
192 console.error(f"Failed to revert commit {commit_hash[:8]}")
193 console.error(f"Error: {result.stderr}")
194 console.error("You may need to resolve conflicts manually:")
195 console.error(f" git revert {commit_hash[:8]}")
196 return False