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

1"""Git tag and commit plumbing for the rollback command. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11from rhiza_tools import console 

12from rhiza_tools.commands._shared import run_git_command 

13 

14# Number of fields in the `%H|%ci|%s` git-show format (commit hash, date, subject). 

15_TAG_DETAIL_FIELDS = 3 

16 

17 

18def _get_tag_commit(tag: str) -> str | None: 

19 """Get the commit hash that a tag points to. 

20 

21 Args: 

22 tag: The tag name. 

23 

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() 

31 

32 

33def _get_tag_details(tag: str) -> dict[str, str]: 

34 """Get details about a tag. 

35 

36 Args: 

37 tag: The tag name. 

38 

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 

54 

55 

56def _is_bump_commit(tag: str) -> bool: 

57 """Check if the commit the tag points to looks like a bump commit. 

58 

59 Bump commits typically have messages like "Bump version: X.Y.Z → A.B.C" 

60 or contain version-related keywords. 

61 

62 Args: 

63 tag: The tag name. 

64 

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 

74 

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) 

78 

79 

80def _get_previous_version_from_tags(current_tag: str) -> str | None: 

81 """Find the previous version tag before the given tag. 

82 

83 Args: 

84 current_tag: The current tag being rolled back. 

85 

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 

95 

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

97 

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 

105 

106 return None 

107 

108 

109def _delete_local_tag(tag: str, dry_run: bool) -> bool: 

110 """Delete a tag from the local repository. 

111 

112 Args: 

113 tag: The tag name to delete. 

114 dry_run: If True, only simulate deletion. 

115 

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 

122 

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 

131 

132 

133def _delete_remote_tag(tag: str, dry_run: bool) -> bool: 

134 """Delete a tag from the remote repository. 

135 

136 Args: 

137 tag: The tag name to delete. 

138 dry_run: If True, only simulate deletion. 

139 

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 

146 

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 

159 

160 

161def _revert_bump_commit(commit_hash: str, dry_run: bool) -> bool: 

162 """Revert the version bump commit. 

163 

164 Creates a new revert commit rather than rewriting history, making 

165 this safe even when the commit has been pushed to remote. 

166 

167 Args: 

168 commit_hash: The commit hash to revert. 

169 dry_run: If True, only simulate the revert. 

170 

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 

182 

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