From 1b53263f87a8cd47daca357d924810431b7acf14 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 8 Dec 2025 23:02:43 +0100 Subject: [PATCH] Release version 0.4.0 --- CHANGELOG.md | 5 + PKGBUILD | 2 +- debian/changelog | 6 + flake.nix | 2 +- package-manager.spec | 2 +- pkgmgr/branch_commands.py | 135 +++++++++++++++++- pkgmgr/release.py | 57 ++++++++ pyproject.toml | 2 +- tests/e2e/test_integration_branch_commands.py | 44 +++++- .../e2e/test_integration_release_commands.py | 126 +++++++--------- tests/unit/pkgmgr/test_cli_branch.py | 49 +++++++ 11 files changed, 348 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7024afa..93ac089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) diff --git a/PKGBUILD b/PKGBUILD index 0fd3b30..6581360 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Kevin Veen-Birkenbach pkgname=package-manager -pkgver=0.2.0 +pkgver=0.4.0 pkgrel=1 pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." arch=('any') diff --git a/debian/changelog b/debian/changelog index 0911570..e2e22c7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 Mon, 08 Dec 2025 23:02:43 +0100 + 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) diff --git a/flake.nix b/flake.nix index e83ead8..67a19a8 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ rec { pkgmgr = pyPkgs.buildPythonApplication { pname = "package-manager"; - version = "0.2.0"; + version = "0.4.0"; # Use the git repo as source src = ./.; diff --git a/package-manager.spec b/package-manager.spec index 49eab11..2dcdfd8 100644 --- a/package-manager.spec +++ b/package-manager.spec @@ -1,5 +1,5 @@ Name: package-manager -Version: 0.2.0 +Version: 0.4.0 Release: 1%{?dist} Summary: Wrapper that runs Kevin's package-manager via Nix flake diff --git a/pkgmgr/branch_commands.py b/pkgmgr/branch_commands.py index 480df1d..2b9657f 100644 --- a/pkgmgr/branch_commands.py +++ b/pkgmgr/branch_commands.py @@ -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 + 6) git pull origin + 7) git merge --no-ff + 8) git push origin + 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 diff --git a/pkgmgr/release.py b/pkgmgr/release.py index 863654e..2a00c3c 100644 --- a/pkgmgr/release.py +++ b/pkgmgr/release.py @@ -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, ) diff --git a/pyproject.toml b/pyproject.toml index f267913..a05ee07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "package-manager" -version = "0.2.0" +version = "0.4.0" description = "Kevin's package-manager tool (pkgmgr)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/e2e/test_integration_branch_commands.py b/tests/e2e/test_integration_branch_commands.py index 69fc7f0..e2d8e5e 100644 --- a/tests/e2e/test_integration_branch_commands.py +++ b/tests/e2e/test_integration_branch_commands.py @@ -11,8 +11,8 @@ class TestIntegrationBranchCommands(unittest.TestCase): Integration tests for the `pkgmgr branch` CLI wiring. These tests execute the real entry point (main.py) and mock - the high-level `open_branch` helper to ensure that argument - parsing and dispatch behave as expected. + the high-level helpers to ensure that argument parsing and + dispatch behave as expected. """ 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("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__": unittest.main() diff --git a/tests/e2e/test_integration_release_commands.py b/tests/e2e/test_integration_release_commands.py index b418768..03f9064 100644 --- a/tests/e2e/test_integration_release_commands.py +++ b/tests/e2e/test_integration_release_commands.py @@ -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 -import os import runpy import sys import unittest - - -PROJECT_ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..") -) +from unittest.mock import patch class TestIntegrationReleaseCommand(unittest.TestCase): - def _run_pkgmgr( - self, - argv: list[str], - expect_success: bool, - ) -> None: - """ - Run the main entry point with the given argv and assert on success/failure. + """Integration tests for `pkgmgr release` wiring.""" - argv must include the program name as argv[0], e.g. "": - ["", "release", "patch", "pkgmgr", "--preview"] + def _run_pkgmgr(self, argv: list[str]) -> None: + """ + 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) - try: sys.argv = argv - try: - # Execute main.py as if called via `python main.py ...` - 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)." - ) + # Entry point: the `pkgmgr` module is the console script. + runpy.run_module("pkgmgr", run_name="__main__") finally: 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 - with a non-zero exit code, but without crashing the interpreter. + Calling `pkgmgr release patch --preview` should *not* enable + the `close` flag by default. """ - argv = [ - "", - "release", - "patch", - "does-not-exist-xyz", - ] - self._run_pkgmgr(argv, expect_success=False) + self._run_pkgmgr(["pkgmgr", "release", "patch", "--preview"]) - def test_release_preview_for_pkgmgr_repository(self) -> None: - """ - Sanity-check the happy path for the CLI: + mock_release.assert_called_once() + _args, kwargs = mock_release.call_args - - Runs `pkgmgr release patch pkgmgr --preview` - - Must exit with code 0 - - Uses the real configuration + repository selection - - Exercises the new --preview mode end-to-end. - """ - argv = [ - "", - "release", - "patch", - "pkgmgr", - "--preview", - ] + # CLI wiring + self.assertEqual(kwargs.get("release_type"), "patch") + self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used") + # Default: no --close → close=False + self.assertFalse(kwargs.get("close"), "close must be False when --close is not given") - original_cwd = os.getcwd() - try: - os.chdir(PROJECT_ROOT) - self._run_pkgmgr(argv, expect_success=True) - finally: - os.chdir(original_cwd) + @patch("pkgmgr.release.release") + def test_release_with_close_flag(self, mock_release) -> None: + """ + Calling `pkgmgr release minor --preview --close` should pass + close=True into pkgmgr.release.release(). + """ + 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__": diff --git a/tests/unit/pkgmgr/test_cli_branch.py b/tests/unit/pkgmgr/test_cli_branch.py index f58ab9d..22e1561 100644 --- a/tests/unit/pkgmgr/test_cli_branch.py +++ b/tests/unit/pkgmgr/test_cli_branch.py @@ -66,6 +66,55 @@ class TestCliBranch(unittest.TestCase): self.assertEqual(call_kwargs.get("base_branch"), "main") 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: """ Unknown branch subcommand should result in SystemExit(2).