Coverage for src/rhiza_tools/commands/release/git.py: 100%

146 statements  

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

1"""Git plumbing for the release command. 

2 

3This module holds every git-facing helper used by ``release_command``: verifying 

4the working tree, checking branch sync status, resolving the default remote 

5branch, looking up and pushing tags, and confirming with the user before the 

6final push. None of these functions perform interactive version selection — 

7that concern lives in ``release/versioning.py``. 

8 

9All symbols defined here are re-exported by ``release.py`` so the public import 

10surface is unchanged. 

11""" 

12 

13from __future__ import annotations 

14 

15import typer 

16 

17from rhiza_tools import console 

18from rhiza_tools.commands._shared import run_git_command 

19 

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

21_TAG_DETAIL_FIELDS = 3 

22# Cap on how many commits are listed when previewing a release. 

23_MAX_COMMITS_SHOWN = 10 

24 

25 

26def get_current_branch() -> str: 

27 """Get the current git branch name for the release flow. 

28 

29 Returns: 

30 The current branch name (e.g. ``"main"``). 

31 

32 Raises: 

33 typer.Exit: If the branch cannot be determined. 

34 

35 Example: 

36 >>> get_current_branch() # doctest: +SKIP 

37 'main' 

38 """ 

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

40 return result.stdout.strip() 

41 

42 

43def check_clean_working_tree() -> None: 

44 """Verify that the working tree is clean (no uncommitted changes). 

45 

46 Raises: 

47 typer.Exit: If there are uncommitted changes in the working tree. 

48 

49 Example: 

50 >>> check_clean_working_tree() # doctest: +SKIP 

51 """ 

52 result = run_git_command(["git", "status", "--porcelain"]) 

53 if result.stdout.strip(): 

54 console.error("You have uncommitted changes:") 

55 console.error(result.stdout) 

56 console.error("Please commit or stash your changes before releasing.") 

57 raise typer.Exit(code=1) 

58 

59 

60def check_branch_status(current_branch: str) -> None: 

61 """Check if the current branch is up-to-date with remote. 

62 

63 Args: 

64 current_branch: The name of the current git branch. 

65 

66 Raises: 

67 typer.Exit: If branch is behind remote or has diverged. 

68 

69 Example: 

70 >>> check_branch_status("main") # doctest: +SKIP 

71 """ 

72 # Fetch latest from remote 

73 console.info("Checking remote status...") 

74 run_git_command(["git", "fetch", "origin"]) 

75 

76 # Get upstream tracking branch 

77 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], check=False) 

78 if result.returncode != 0: 

79 console.error(f"No upstream branch configured for {current_branch}") 

80 console.error(f"Set upstream with: git push -u origin {current_branch}") 

81 raise typer.Exit(code=1) 

82 

83 upstream = result.stdout.strip() 

84 

85 # Get commit hashes 

86 local = run_git_command(["git", "rev-parse", "@"]).stdout.strip() 

87 remote = run_git_command(["git", "rev-parse", upstream]).stdout.strip() 

88 base = run_git_command(["git", "merge-base", "@", upstream]).stdout.strip() 

89 

90 if local != remote: 

91 if local == base: 

92 # Local is behind remote (need to pull) 

93 console.error(f"Your branch is behind '{upstream}'.") 

94 console.error("Pull the latest changes before releasing:") 

95 console.error(f" git pull origin {current_branch}") 

96 raise typer.Exit(code=1) 

97 # Either local is ahead of remote OR branches have diverged 

98 # Check if remote == base to distinguish between the two cases 

99 elif remote == base: 

100 # Local is ahead of remote (need to push) 

101 console.warning(f"Your branch is ahead of '{upstream}'.") 

102 console.info("Unpushed commits:") 

103 result = run_git_command(["git", "log", "--oneline", "--graph", "--decorate", f"{upstream}..HEAD"]) 

104 console.info(result.stdout) 

105 console.warning("Please push changes to remote before releasing.") 

106 raise typer.Exit(code=1) 

107 else: 

108 # Branches have diverged (need to merge or rebase) 

109 console.error(f"Your branch has diverged from '{upstream}'.") 

110 console.error("To reconcile, choose one of:") 

111 console.error(f" Rebase: git pull --rebase origin {current_branch}") 

112 console.error(f" Merge: git merge origin/{current_branch}") 

113 console.error("Then resolve any conflicts and retry.") 

114 raise typer.Exit(code=1) 

115 

116 

117def get_default_branch() -> str: 

118 """Get the default branch name from the remote repository. 

119 

120 Returns: 

121 The name of the default branch (e.g., "main" or "master"). 

122 

123 Raises: 

124 typer.Exit: If the default branch cannot be determined. 

125 

126 Example: 

127 >>> branch = get_default_branch() # doctest: +SKIP 

128 >>> print(branch) # doctest: +SKIP 

129 main 

130 """ 

131 result = run_git_command(["git", "remote", "show", "origin"], check=False) 

132 if result.returncode != 0: 

133 console.error("Could not determine default branch from remote") 

134 raise typer.Exit(code=1) 

135 

136 for line in result.stdout.split("\n"): 

137 if "HEAD branch" in line: 

138 return str(line.split()[-1]) 

139 

140 console.error("Could not determine default branch from remote") 

141 raise typer.Exit(code=1) 

142 

143 

144def check_tag_exists(tag: str) -> tuple[bool, bool]: 

145 """Check if a tag exists locally and/or remotely. 

146 

147 Args: 

148 tag: The tag name to check. 

149 

150 Returns: 

151 Tuple of (exists_locally, exists_remotely). 

152 

153 Example: 

154 >>> local, remote = check_tag_exists("v1.0.0") # doctest: +SKIP 

155 >>> if remote: # doctest: +SKIP 

156 ... print("Tag already released") # doctest: +SKIP 

157 """ 

158 # Check local 

159 result = run_git_command(["git", "rev-parse", tag], check=False) 

160 exists_locally = result.returncode == 0 

161 

162 # Check remote 

163 result = run_git_command(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"], check=False) 

164 exists_remotely = result.returncode == 0 

165 

166 return exists_locally, exists_remotely 

167 

168 

169def push_tag(tag: str, dry_run: bool = False) -> None: 

170 """Push a git tag to the remote repository. 

171 

172 Args: 

173 tag: The tag name to push. 

174 dry_run: If True, only show what would be done. 

175 

176 Raises: 

177 typer.Exit: If push fails. 

178 

179 Example: 

180 >>> push_tag("v1.0.0") # doctest: +SKIP 

181 """ 

182 command = ["git", "push", "origin", f"refs/tags/{tag}"] 

183 

184 if dry_run: 

185 dry_run_header = typer.style("[DRY-RUN] Would execute:", fg=typer.colors.YELLOW, bold=True) 

186 console.info(f"\n{dry_run_header} {' '.join(command)}") 

187 

188 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True) 

189 console.info(f"[DRY-RUN] Release tag {tag_styled} would be pushed to remote") 

190 console.info("[DRY-RUN] This would trigger the release workflow") 

191 

192 # Show what would be pushed 

193 result = run_git_command(["git", "show", "-s", "--format=%H %s", tag], check=False) 

194 if result.returncode == 0 and result.stdout.strip(): 

195 console.info(f"[DRY-RUN] Tag points to: {result.stdout.strip()}") 

196 else: 

197 console.info(f"\n{typer.style('Pushing tag to remote...', fg=typer.colors.CYAN, bold=True)}") 

198 console.info(f"Command: {' '.join(command)}") 

199 run_git_command(command) 

200 

201 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True) 

202 success_msg = ( 

203 f"\n{typer.style('✓', fg=typer.colors.GREEN, bold=True)} Release tag {tag_styled} pushed to remote!" 

204 ) 

205 console.success(success_msg) 

206 console.info("The release workflow will now be triggered automatically.") 

207 

208 # Get repository URL for GitHub Actions link 

209 result = run_git_command(["git", "remote", "get-url", "origin"]) 

210 repo_url = result.stdout.strip() 

211 

212 # Try to extract GitHub repository path for displaying the Actions URL 

213 # Support both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) formats 

214 repo_path = None 

215 if repo_url.startswith("git@github.com:"): 

216 # SSH format: git@github.com:user/repo.git 

217 repo_path = repo_url[len("git@github.com:") :].rstrip(".git") 

218 elif repo_url.startswith("https://github.com/"): 

219 # HTTPS format: https://github.com/user/repo.git 

220 repo_path = repo_url[len("https://github.com/") :].rstrip(".git") 

221 

222 if repo_path: 

223 console.info(f"Monitor progress at: https://github.com/{repo_path}/actions") 

224 

225 

226def _validate_tag_state(tag: str, current_version: str) -> None: 

227 """Validate that tag exists locally but not remotely. 

228 

229 Args: 

230 tag: Tag name to check. 

231 current_version: Current version string. 

232 

233 Raises: 

234 typer.Exit: If tag state is invalid. 

235 """ 

236 exists_locally, exists_remotely = check_tag_exists(tag) 

237 

238 if exists_remotely: 

239 console.error(f"Tag '{tag}' already exists on remote") 

240 console.error(f"The release for version {current_version} has already been published.") 

241 console.error("If this was unintentional, you can delete the remote tag and retry:") 

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

243 raise typer.Exit(code=1) 

244 

245 if not exists_locally: 

246 console.error(f"Tag '{tag}' does not exist locally") 

247 console.error("Create the tag by bumping the version with commit enabled:") 

248 console.error(" rhiza-tools bump <version> --commit") 

249 console.error("Or use release with --bump to do both at once:") 

250 console.error(" rhiza-tools release --bump <PATCH|MINOR|MAJOR> --push") 

251 raise typer.Exit(code=1) 

252 

253 console.success(f"Tag '{tag}' found locally") 

254 

255 # Show tag details 

256 result = run_git_command(["git", "show", "-s", "--format=%H|%ci|%s", tag], check=False) 

257 if result.returncode == 0 and result.stdout.strip(): 

258 parts = result.stdout.strip().split("|") 

259 if len(parts) == _TAG_DETAIL_FIELDS: 

260 commit_hash, commit_date, commit_msg = parts 

261 console.info(f" Commit: {commit_hash[:8]}") 

262 console.info(f" Date: {commit_date}") 

263 console.info(f" Message: {commit_msg}") 

264 

265 

266def _show_commits_since_last_tag(tag: str) -> None: 

267 """Show commits included since the last tag. 

268 

269 Args: 

270 tag: Current tag. 

271 """ 

272 result = run_git_command(["git", "tag", "--sort=-version:refname", "--merged", "HEAD"], check=False) 

273 if result.returncode != 0: 

274 return 

275 

276 tags = [t.strip() for t in result.stdout.split("\n") if t.strip() and t.strip() != tag] 

277 if not tags: 

278 return 

279 

280 last_tag = tags[0] # Most recent tag (excluding current) 

281 

282 # Get commit list 

283 log_result = run_git_command( 

284 ["git", "log", f"{last_tag}..{tag}", "--oneline", "--no-decorate"], 

285 check=False, 

286 ) 

287 if log_result.returncode == 0 and log_result.stdout.strip(): 

288 commits = log_result.stdout.strip().split("\n") 

289 console.info(f"\nCommits included in this release (since {last_tag}):") 

290 for commit in commits[:_MAX_COMMITS_SHOWN]: 

291 console.info(f"{commit}") 

292 if len(commits) > _MAX_COMMITS_SHOWN: 

293 console.info(f" ... and {len(commits) - _MAX_COMMITS_SHOWN} more") 

294 

295 

296def _confirm_and_push_tag( 

297 tag: str, 

298 push: bool, 

299 dry_run: bool, 

300 non_interactive: bool, 

301 bump_branch: str | None = None, 

302) -> None: 

303 """Confirm with user and push tag to remote. 

304 

305 When *bump_branch* is provided the bump commit is pushed to the remote 

306 **before** the tag so that the tag references a commit that exists on 

307 the remote. 

308 

309 Args: 

310 tag: Tag to push. 

311 push: If True, push without confirmation. 

312 dry_run: If True, only simulate push. 

313 non_interactive: If True, skip confirmation. 

314 bump_branch: If set, push this branch first (bump commit). 

315 

316 Raises: 

317 typer.Exit: If user declines to push. 

318 """ 

319 should_push = push 

320 if not non_interactive and not push: 

321 should_push = typer.confirm("Push tag to remote and trigger release workflow?", default=False) 

322 if not should_push: 

323 console.info("Release cancelled by user") 

324 raise typer.Exit(code=0) 

325 

326 if should_push: 

327 if dry_run: 

328 if bump_branch: 

329 console.info(f"[DRY-RUN] Would push bump commit on '{bump_branch}' to remote") 

330 console.info(f"[DRY-RUN] Would push tag '{tag}' to remote") 

331 else: 

332 # Push the bump commit first so the tag references a known commit 

333 if bump_branch: 

334 console.info("Pushing bump commit to remote...") 

335 run_git_command(["git", "push", "origin", bump_branch]) 

336 push_tag(tag, dry_run=False)