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

@@ -1,3 +1,8 @@
## [0.4.0] - 2025-12-08
* Add branch closing helper and --close flag to release command, including CLI wiring and tests (see https://chatgpt.com/share/69374aec-74ec-800f-bde3-5d91dfdb9b91)
## [0.2.0] - 2025-12-08 ## [0.2.0] - 2025-12-08
* Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) * Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=0.2.0 pkgver=0.4.0
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
package-manager (0.4.0-1) unstable; urgency=medium
* Add branch closing helper and --close flag to release command, including CLI wiring and tests (see https://chatgpt.com/share/69374aec-74ec-800f-bde3-5d91dfdb9b91)
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 08 Dec 2025 23:02:43 +0100
package-manager (0.2.0-1) unstable; urgency=medium package-manager (0.2.0-1) unstable; urgency=medium
* Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) * Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)

View File

@@ -31,7 +31,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "0.2.0"; version = "0.4.0";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 0.2.0 Version: 0.4.0
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import Optional 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( def open_branch(
@@ -78,3 +78,136 @@ def open_branch(
raise RuntimeError( raise RuntimeError(
f"Failed to push new branch {name!r} to origin: {exc}" f"Failed to push new branch {name!r} to origin: {exc}"
) from 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). 1) Preview-only run (dry-run).
2) Interactive confirmation, then real release if confirmed. 2) Interactive confirmation, then real release if confirmed.
This confirmation can be skipped with the `-f/--force` flag. 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 from __future__ import annotations
@@ -37,6 +40,7 @@ from datetime import date, datetime
from typing import Optional, Tuple from typing import Optional, Tuple
from pkgmgr.git_utils import get_tags, get_current_branch, GitError from pkgmgr.git_utils import get_tags, get_current_branch, GitError
from pkgmgr.branch_commands import close_branch
from pkgmgr.versioning import ( from pkgmgr.versioning import (
SemVer, SemVer,
find_latest_version, find_latest_version,
@@ -613,6 +617,7 @@ def _release_impl(
release_type: str = "patch", release_type: str = "patch",
message: Optional[str] = None, message: Optional[str] = None,
preview: bool = False, preview: bool = False,
close: bool = False,
) -> None: ) -> None:
""" """
Internal implementation that performs a single-phase release. Internal implementation that performs a single-phase release.
@@ -625,6 +630,8 @@ def _release_impl(
If `preview` is False: If `preview` is False:
- Files are updated. - Files are updated.
- Git commit, tag, and push are executed. - 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. # 1) Determine the current version from Git tags.
current_ver = _determine_current_version() 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 tag -a {new_tag} -m "{tag_msg}"')
print(f"[PREVIEW] Would run: git push origin {branch}") print(f"[PREVIEW] Would run: git push origin {branch}")
print("[PREVIEW] Would run: git push origin --tags") 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.") print("Preview completed. No changes were made.")
return return
@@ -714,6 +733,24 @@ def _release_impl(
print(f"Release {new_ver_str} completed.") 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) # Public release entry point (with preview-first + confirmation logic)
@@ -727,6 +764,7 @@ def release(
message: Optional[str] = None, message: Optional[str] = None,
preview: bool = False, preview: bool = False,
force: bool = False, force: bool = False,
close: bool = False,
) -> None: ) -> None:
""" """
High-level release entry point. High-level release entry point.
@@ -753,6 +791,9 @@ def release(
* In non-interactive environments (stdin not a TTY), the * In non-interactive environments (stdin not a TTY), the
confirmation step is skipped automatically and a single confirmation step is skipped automatically and a single
REAL phase is executed, to avoid blocking on input(). 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. # Explicit preview mode: just do a single PREVIEW phase and exit.
if preview: if preview:
@@ -762,6 +803,7 @@ def release(
release_type=release_type, release_type=release_type,
message=message, message=message,
preview=True, preview=True,
close=close,
) )
return return
@@ -773,6 +815,7 @@ def release(
release_type=release_type, release_type=release_type,
message=message, message=message,
preview=False, preview=False,
close=close,
) )
return return
@@ -784,6 +827,7 @@ def release(
release_type=release_type, release_type=release_type,
message=message, message=message,
preview=False, preview=False,
close=close,
) )
return return
@@ -795,6 +839,7 @@ def release(
release_type=release_type, release_type=release_type,
message=message, message=message,
preview=True, preview=True,
close=close,
) )
# Ask for confirmation # Ask for confirmation
@@ -815,6 +860,7 @@ def release(
release_type=release_type, release_type=release_type,
message=message, message=message,
preview=False, preview=False,
close=close,
) )
@@ -867,6 +913,16 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
"release directly." "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) return parser.parse_args(argv)
@@ -879,4 +935,5 @@ if __name__ == "__main__":
message=args.message, message=args.message,
preview=args.preview, preview=args.preview,
force=args.force, force=args.force,
close=args.close,
) )

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "package-manager" name = "package-manager"
version = "0.2.0" version = "0.4.0"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -11,8 +11,8 @@ class TestIntegrationBranchCommands(unittest.TestCase):
Integration tests for the `pkgmgr branch` CLI wiring. Integration tests for the `pkgmgr branch` CLI wiring.
These tests execute the real entry point (main.py) and mock These tests execute the real entry point (main.py) and mock
the high-level `open_branch` helper to ensure that argument the high-level helpers to ensure that argument parsing and
parsing and dispatch behave as expected. dispatch behave as expected.
""" """
def _run_pkgmgr(self, extra_args: list[str]) -> None: def _run_pkgmgr(self, extra_args: list[str]) -> None:
@@ -64,6 +64,46 @@ class TestIntegrationBranchCommands(unittest.TestCase):
self.assertEqual(kwargs.get("base_branch"), "main") self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".") self.assertEqual(kwargs.get("cwd"), ".")
# ------------------------------------------------------------------
# close subcommand
# ------------------------------------------------------------------
@patch("pkgmgr.cli_core.commands.branch.close_branch")
def test_branch_close_with_name_and_base(self, mock_close_branch) -> None:
"""
`pkgmgr branch close feature/test --base develop` must forward
the name and base branch to close_branch() with cwd=".".
"""
self._run_pkgmgr(
["branch", "close", "feature/test", "--base", "develop"]
)
mock_close_branch.assert_called_once()
_, kwargs = mock_close_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/test")
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli_core.commands.branch.close_branch")
def test_branch_close_without_name_uses_default_base(
self,
mock_close_branch,
) -> None:
"""
`pkgmgr branch close` without a name must still call close_branch(),
passing name=None and the default base branch 'main'.
The branch helper will then resolve the actual base (main/master)
internally.
"""
self._run_pkgmgr(["branch", "close"])
mock_close_branch.assert_called_once()
_, kwargs = mock_close_branch.call_args
self.assertIsNone(kwargs.get("name"))
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,99 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
End-to-end style integration tests for the `pkgmgr release` CLI command.
These tests exercise the top-level `pkgmgr` entry point by invoking
the module as `__main__` and verifying that the underlying
`pkgmgr.release.release()` function is called with the expected
arguments, in particular the new `close` flag.
"""
from __future__ import annotations from __future__ import annotations
import os
import runpy import runpy
import sys import sys
import unittest import unittest
from unittest.mock import patch
PROJECT_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
class TestIntegrationReleaseCommand(unittest.TestCase): class TestIntegrationReleaseCommand(unittest.TestCase):
def _run_pkgmgr( """Integration tests for `pkgmgr release` wiring."""
self,
argv: list[str],
expect_success: bool,
) -> None:
"""
Run the main entry point with the given argv and assert on success/failure.
argv must include the program name as argv[0], e.g. "": def _run_pkgmgr(self, argv: list[str]) -> None:
["", "release", "patch", "pkgmgr", "--preview"] """
Helper to invoke the `pkgmgr` console script via `run_module`.
This simulates a real CLI call like:
pkgmgr release minor --preview --close
""" """
cmd_repr = " ".join(argv[1:])
original_argv = list(sys.argv) original_argv = list(sys.argv)
try: try:
sys.argv = argv sys.argv = argv
try: # Entry point: the `pkgmgr` module is the console script.
# Execute main.py as if called via `python main.py ...` runpy.run_module("pkgmgr", run_name="__main__")
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else 1
if expect_success and code != 0:
print()
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {code}. "
"Scroll up to inspect the output printed before failure."
) from exc
if not expect_success and code == 0:
print()
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
raise AssertionError(
f"{cmd_repr!r} unexpectedly succeeded with exit code 0."
) from exc
else:
# No SystemExit: treat as success when expect_success is True,
# otherwise as a failure (we expected a non-zero exit).
if not expect_success:
raise AssertionError(
f"{cmd_repr!r} returned normally (expected non-zero exit)."
)
finally: finally:
sys.argv = original_argv sys.argv = original_argv
def test_release_for_unknown_repo_fails_cleanly(self) -> None: @patch("pkgmgr.release.release")
def test_release_without_close_flag(self, mock_release) -> None:
""" """
Releasing a non-existent repository identifier must fail Calling `pkgmgr release patch --preview` should *not* enable
with a non-zero exit code, but without crashing the interpreter. the `close` flag by default.
""" """
argv = [ self._run_pkgmgr(["pkgmgr", "release", "patch", "--preview"])
"",
"release",
"patch",
"does-not-exist-xyz",
]
self._run_pkgmgr(argv, expect_success=False)
def test_release_preview_for_pkgmgr_repository(self) -> None: mock_release.assert_called_once()
""" _args, kwargs = mock_release.call_args
Sanity-check the happy path for the CLI:
- Runs `pkgmgr release patch pkgmgr --preview` # CLI wiring
- Must exit with code 0 self.assertEqual(kwargs.get("release_type"), "patch")
- Uses the real configuration + repository selection self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used")
- Exercises the new --preview mode end-to-end. # Default: no --close → close=False
""" self.assertFalse(kwargs.get("close"), "close must be False when --close is not given")
argv = [
"",
"release",
"patch",
"pkgmgr",
"--preview",
]
original_cwd = os.getcwd() @patch("pkgmgr.release.release")
try: def test_release_with_close_flag(self, mock_release) -> None:
os.chdir(PROJECT_ROOT) """
self._run_pkgmgr(argv, expect_success=True) Calling `pkgmgr release minor --preview --close` should pass
finally: close=True into pkgmgr.release.release().
os.chdir(original_cwd) """
self._run_pkgmgr(["pkgmgr", "release", "minor", "--preview", "--close"])
mock_release.assert_called_once()
_args, kwargs = mock_release.call_args
# CLI wiring
self.assertEqual(kwargs.get("release_type"), "minor")
self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used")
# With --close → close=True
self.assertTrue(kwargs.get("close"), "close must be True when --close is given")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -66,6 +66,55 @@ class TestCliBranch(unittest.TestCase):
self.assertEqual(call_kwargs.get("base_branch"), "main") self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".") self.assertEqual(call_kwargs.get("cwd"), ".")
# ------------------------------------------------------------------
# close subcommand
# ------------------------------------------------------------------
@patch("pkgmgr.cli_core.commands.branch.close_branch")
def test_handle_branch_close_forwards_args_to_close_branch(self, mock_close_branch) -> None:
"""
handle_branch('close') should call close_branch with name, base and cwd='.'.
"""
args = SimpleNamespace(
command="branch",
subcommand="close",
name="feature/cli-close",
base="develop",
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_close_branch.assert_called_once()
_, call_kwargs = mock_close_branch.call_args
self.assertEqual(call_kwargs.get("name"), "feature/cli-close")
self.assertEqual(call_kwargs.get("base_branch"), "develop")
self.assertEqual(call_kwargs.get("cwd"), ".")
@patch("pkgmgr.cli_core.commands.branch.close_branch")
def test_handle_branch_close_uses_default_base_when_not_set(self, mock_close_branch) -> None:
"""
If --base is not passed for 'close', argparse gives base='main'
(default), and handle_branch should propagate that to close_branch.
"""
args = SimpleNamespace(
command="branch",
subcommand="close",
name=None,
base="main",
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_close_branch.assert_called_once()
_, call_kwargs = mock_close_branch.call_args
self.assertIsNone(call_kwargs.get("name"))
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None: def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None:
""" """
Unknown branch subcommand should result in SystemExit(2). Unknown branch subcommand should result in SystemExit(2).