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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
1"""Git plumbing for the release command.
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``.
9All symbols defined here are re-exported by ``release.py`` so the public import
10surface is unchanged.
11"""
13from __future__ import annotations
15import typer
17from rhiza_tools import console
18from rhiza_tools.commands._shared import run_git_command
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
26def get_current_branch() -> str:
27 """Get the current git branch name for the release flow.
29 Returns:
30 The current branch name (e.g. ``"main"``).
32 Raises:
33 typer.Exit: If the branch cannot be determined.
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()
43def check_clean_working_tree() -> None:
44 """Verify that the working tree is clean (no uncommitted changes).
46 Raises:
47 typer.Exit: If there are uncommitted changes in the working tree.
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)
60def check_branch_status(current_branch: str) -> None:
61 """Check if the current branch is up-to-date with remote.
63 Args:
64 current_branch: The name of the current git branch.
66 Raises:
67 typer.Exit: If branch is behind remote or has diverged.
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"])
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)
83 upstream = result.stdout.strip()
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()
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)
117def get_default_branch() -> str:
118 """Get the default branch name from the remote repository.
120 Returns:
121 The name of the default branch (e.g., "main" or "master").
123 Raises:
124 typer.Exit: If the default branch cannot be determined.
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)
136 for line in result.stdout.split("\n"):
137 if "HEAD branch" in line:
138 return str(line.split()[-1])
140 console.error("Could not determine default branch from remote")
141 raise typer.Exit(code=1)
144def check_tag_exists(tag: str) -> tuple[bool, bool]:
145 """Check if a tag exists locally and/or remotely.
147 Args:
148 tag: The tag name to check.
150 Returns:
151 Tuple of (exists_locally, exists_remotely).
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
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
166 return exists_locally, exists_remotely
169def push_tag(tag: str, dry_run: bool = False) -> None:
170 """Push a git tag to the remote repository.
172 Args:
173 tag: The tag name to push.
174 dry_run: If True, only show what would be done.
176 Raises:
177 typer.Exit: If push fails.
179 Example:
180 >>> push_tag("v1.0.0") # doctest: +SKIP
181 """
182 command = ["git", "push", "origin", f"refs/tags/{tag}"]
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)}")
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")
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)
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.")
208 # Get repository URL for GitHub Actions link
209 result = run_git_command(["git", "remote", "get-url", "origin"])
210 repo_url = result.stdout.strip()
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")
222 if repo_path:
223 console.info(f"Monitor progress at: https://github.com/{repo_path}/actions")
226def _validate_tag_state(tag: str, current_version: str) -> None:
227 """Validate that tag exists locally but not remotely.
229 Args:
230 tag: Tag name to check.
231 current_version: Current version string.
233 Raises:
234 typer.Exit: If tag state is invalid.
235 """
236 exists_locally, exists_remotely = check_tag_exists(tag)
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)
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)
253 console.success(f"Tag '{tag}' found locally")
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}")
266def _show_commits_since_last_tag(tag: str) -> None:
267 """Show commits included since the last tag.
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
276 tags = [t.strip() for t in result.stdout.split("\n") if t.strip() and t.strip() != tag]
277 if not tags:
278 return
280 last_tag = tags[0] # Most recent tag (excluding current)
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")
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.
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.
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).
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)
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)