Release version 0.4.0

This commit is contained in:
Kevin Veen-Birkenbach
2025-12-08 23:02:43 +01:00
parent 71823c2f48
commit 1b53263f87
11 changed files with 348 additions and 82 deletions

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import Optional
from pkgmgr.git_utils import run_git, GitError
from pkgmgr.git_utils import run_git, GitError, get_current_branch
def open_branch(
@@ -78,3 +78,136 @@ def open_branch(
raise RuntimeError(
f"Failed to push new branch {name!r} to origin: {exc}"
) from exc
def _resolve_base_branch(
preferred: str,
fallback: str,
cwd: str,
) -> str:
"""
Resolve the base branch to use for merging.
Try `preferred` (default: main) first, then `fallback` (default: master).
Raise RuntimeError if neither exists.
"""
for candidate in (preferred, fallback):
try:
run_git(["rev-parse", "--verify", candidate], cwd=cwd)
return candidate
except GitError:
continue
raise RuntimeError(
f"Neither {preferred!r} nor {fallback!r} exist in this repository."
)
def close_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
) -> None:
"""
Merge a feature branch into the main/master branch and optionally delete it.
Steps:
1) Determine branch name (argument or current branch)
2) Resolve base branch (prefers `base_branch`, falls back to `fallback_base`)
3) Ask for confirmation (y/N)
4) git fetch origin
5) git checkout <base>
6) git pull origin <base>
7) git merge --no-ff <name>
8) git push origin <base>
9) Delete branch locally and on origin
If the user does not confirm with 'y', the operation is aborted.
"""
# 1) Determine which branch to close
if not name:
try:
name = get_current_branch(cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
if not name:
raise RuntimeError("Branch name must not be empty.")
# 2) Resolve base branch (main/master)
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
f"Refusing to close base branch {target_base!r}. "
"Please specify a feature branch."
)
# 3) Confirmation prompt
prompt = (
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? "
"(y/N): "
)
answer = input(prompt).strip().lower()
if answer != "y":
print("Aborted closing branch.")
return
# 4) Fetch from origin
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc
# 5) Checkout base branch
try:
run_git(["checkout", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc
# 6) Pull latest base
try:
run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc
# 7) Merge feature branch into base
try:
run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to merge branch {name!r} into {target_base!r}: {exc}"
) from exc
# 8) Push updated base
try:
run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push base branch {target_base!r} to origin after merge: {exc}"
) from exc
# 9) Delete feature branch locally
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to delete local branch {name!r} after merge: {exc}"
) from exc
# 10) Delete feature branch on origin (best effort)
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
# Remote delete is nice-to-have; surface as RuntimeError for clarity.
raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -23,6 +23,9 @@ Additional behaviour:
1) Preview-only run (dry-run).
2) Interactive confirmation, then real release if confirmed.
This confirmation can be skipped with the `-f/--force` flag.
- If `-c/--close` is used and the current branch is not main/master,
the branch will be closed via branch_commands.close_branch() after
a successful release.
"""
from __future__ import annotations
@@ -37,6 +40,7 @@ from datetime import date, datetime
from typing import Optional, Tuple
from pkgmgr.git_utils import get_tags, get_current_branch, GitError
from pkgmgr.branch_commands import close_branch
from pkgmgr.versioning import (
SemVer,
find_latest_version,
@@ -613,6 +617,7 @@ def _release_impl(
release_type: str = "patch",
message: Optional[str] = None,
preview: bool = False,
close: bool = False,
) -> None:
"""
Internal implementation that performs a single-phase release.
@@ -625,6 +630,8 @@ def _release_impl(
If `preview` is False:
- Files are updated.
- Git commit, tag, and push are executed.
- If `close` is True and the current branch is not main/master,
the branch will be closed after a successful release.
"""
# 1) Determine the current version from Git tags.
current_ver = _determine_current_version()
@@ -701,6 +708,18 @@ def _release_impl(
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
print(f"[PREVIEW] Would run: git push origin {branch}")
print("[PREVIEW] Would run: git push origin --tags")
if close and branch not in ("main", "master"):
print(
f"[PREVIEW] Would also close branch {branch} after the release "
"(--close specified and branch is not main/master)."
)
elif close:
print(
f"[PREVIEW] --close specified but current branch is {branch}; "
"no branch would be closed."
)
print("Preview completed. No changes were made.")
return
@@ -714,6 +733,24 @@ def _release_impl(
print(f"Release {new_ver_str} completed.")
# Optional: close the current branch after a successful release.
if close:
if branch in ("main", "master"):
print(
f"[INFO] --close specified but current branch is {branch}; "
"nothing to close."
)
return
print(
f"[INFO] Closing branch {branch} after successful release "
"(--close enabled and branch is not main/master)..."
)
try:
close_branch(name=branch, base_branch="main", cwd=".")
except Exception as exc: # pragma: no cover - defensive
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
# ---------------------------------------------------------------------------
# Public release entry point (with preview-first + confirmation logic)
@@ -727,6 +764,7 @@ def release(
message: Optional[str] = None,
preview: bool = False,
force: bool = False,
close: bool = False,
) -> None:
"""
High-level release entry point.
@@ -753,6 +791,9 @@ def release(
* In non-interactive environments (stdin not a TTY), the
confirmation step is skipped automatically and a single
REAL phase is executed, to avoid blocking on input().
The `close` flag controls whether the current branch should be
closed after a successful REAL release (only if it is not main/master).
"""
# Explicit preview mode: just do a single PREVIEW phase and exit.
if preview:
@@ -762,6 +803,7 @@ def release(
release_type=release_type,
message=message,
preview=True,
close=close,
)
return
@@ -773,6 +815,7 @@ def release(
release_type=release_type,
message=message,
preview=False,
close=close,
)
return
@@ -784,6 +827,7 @@ def release(
release_type=release_type,
message=message,
preview=False,
close=close,
)
return
@@ -795,6 +839,7 @@ def release(
release_type=release_type,
message=message,
preview=True,
close=close,
)
# Ask for confirmation
@@ -815,6 +860,7 @@ def release(
release_type=release_type,
message=message,
preview=False,
close=close,
)
@@ -867,6 +913,16 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
"release directly."
),
)
parser.add_argument(
"-c",
"--close",
dest="close",
action="store_true",
help=(
"Close the current branch after a successful release, "
"if it is not main/master."
),
)
return parser.parse_args(argv)
@@ -879,4 +935,5 @@ if __name__ == "__main__":
message=args.message,
preview=args.preview,
force=args.force,
close=args.close,
)