From b9b64fed7de6631722f53d93b7c70130ee190f1d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 8 Dec 2025 18:37:59 +0100 Subject: [PATCH] Add branch CLI command and tests (see ChatGPT conversation: https://chatgpt.com/share/69370ce1-8090-800f-8b08-8ecfa5089a74) Signed-off-by: Kevin Veen-Birkenbach --- pkgmgr/branch_commands.py | 80 ++++++++++++++ pkgmgr/cli_core/commands/__init__.py | 2 + pkgmgr/cli_core/commands/branch.py | 25 +++++ pkgmgr/cli_core/dispatch.py | 6 ++ pkgmgr/cli_core/parser.py | 31 +++++- tests/e2e/test_integration_branch_commands.py | 63 +++++++++++ tests/unit/pkgmgr/test_branch_commands.py | 100 ++++++++++++++++++ tests/unit/pkgmgr/test_cli_branch.py | 86 +++++++++++++++ 8 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 pkgmgr/branch_commands.py create mode 100644 pkgmgr/cli_core/commands/branch.py create mode 100644 tests/e2e/test_integration_branch_commands.py create mode 100644 tests/unit/pkgmgr/test_branch_commands.py create mode 100644 tests/unit/pkgmgr/test_cli_branch.py diff --git a/pkgmgr/branch_commands.py b/pkgmgr/branch_commands.py new file mode 100644 index 0000000..480df1d --- /dev/null +++ b/pkgmgr/branch_commands.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +High-level helpers for branch-related operations. + +This module encapsulates the actual Git logic so the CLI layer +(pkgmgr.cli_core.commands.branch) stays thin and testable. +""" + +from __future__ import annotations + +from typing import Optional + +from pkgmgr.git_utils import run_git, GitError + + +def open_branch( + name: Optional[str], + base_branch: str = "main", + cwd: str = ".", +) -> None: + """ + Create and push a new feature branch on top of `base_branch`. + + Steps: + 1) git fetch origin + 2) git checkout + 3) git pull origin + 4) git checkout -b + 5) git push -u origin + + If `name` is None or empty, the user is prompted on stdin. + """ + + if not name: + name = input("Enter new branch name: ").strip() + + if not name: + raise RuntimeError("Branch name must not be empty.") + + # 1) Fetch from origin + try: + run_git(["fetch", "origin"], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to fetch from origin before creating branch {name!r}: {exc}" + ) from exc + + # 2) Checkout base branch + try: + run_git(["checkout", base_branch], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to checkout base branch {base_branch!r}: {exc}" + ) from exc + + # 3) Pull latest changes on base + try: + run_git(["pull", "origin", base_branch], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to pull latest changes for base branch {base_branch!r}: {exc}" + ) from exc + + # 4) Create new branch + try: + run_git(["checkout", "-b", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to create new branch {name!r} from base {base_branch!r}: {exc}" + ) from exc + + # 5) Push and set upstream + try: + run_git(["push", "-u", "origin", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to push new branch {name!r} to origin: {exc}" + ) from exc diff --git a/pkgmgr/cli_core/commands/__init__.py b/pkgmgr/cli_core/commands/__init__.py index d75e310..d07d9a8 100644 --- a/pkgmgr/cli_core/commands/__init__.py +++ b/pkgmgr/cli_core/commands/__init__.py @@ -5,6 +5,7 @@ from .release import handle_release from .version import handle_version from .make import handle_make from .changelog import handle_changelog +from .branch import handle_branch __all__ = [ "handle_repos_command", @@ -14,4 +15,5 @@ __all__ = [ "handle_version", "handle_make", "handle_changelog", + "handle_branch", ] diff --git a/pkgmgr/cli_core/commands/branch.py b/pkgmgr/cli_core/commands/branch.py new file mode 100644 index 0000000..4d95e96 --- /dev/null +++ b/pkgmgr/cli_core/commands/branch.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys + +from pkgmgr.cli_core.context import CLIContext +from pkgmgr.branch_commands import open_branch + + +def handle_branch(args, ctx: CLIContext) -> None: + """ + Handle `pkgmgr branch` subcommands. + + Currently supported: + - pkgmgr branch open [] [--base ] + """ + if args.subcommand == "open": + open_branch( + name=getattr(args, "name", None), + base_branch=getattr(args, "base", "main"), + cwd=".", + ) + return + + print(f"Unknown branch subcommand: {args.subcommand}") + sys.exit(2) diff --git a/pkgmgr/cli_core/dispatch.py b/pkgmgr/cli_core/dispatch.py index 00c575f..690982a 100644 --- a/pkgmgr/cli_core/dispatch.py +++ b/pkgmgr/cli_core/dispatch.py @@ -15,6 +15,7 @@ from pkgmgr.cli_core.commands import ( handle_config, handle_make, handle_changelog, + handle_branch, ) @@ -47,6 +48,7 @@ def dispatch_command(args, ctx: CLIContext) -> None: "version", "make", "changelog", + # intentionally NOT "branch" – it operates on cwd only ] if args.command in commands_with_selection: @@ -83,6 +85,10 @@ def dispatch_command(args, ctx: CLIContext) -> None: handle_config(args, ctx) elif args.command == "make": handle_make(args, ctx, selected) + elif args.command == "branch": + # Branch commands currently operate on the current working + # directory only, not on the pkgmgr repository selection. + handle_branch(args, ctx) else: print(f"Unknown command: {args.command}") sys.exit(2) diff --git a/pkgmgr/cli_core/parser.py b/pkgmgr/cli_core/parser.py index 0eeefaf..f77247f 100644 --- a/pkgmgr/cli_core/parser.py +++ b/pkgmgr/cli_core/parser.py @@ -269,6 +269,35 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: default=[], ) + # ------------------------------------------------------------ + # branch + # ------------------------------------------------------------ + branch_parser = subparsers.add_parser( + "branch", + help="Branch-related utilities (e.g. open feature branches)", + ) + branch_subparsers = branch_parser.add_subparsers( + dest="subcommand", + help="Branch subcommands", + required=True, + ) + + branch_open = branch_subparsers.add_parser( + "open", + help="Create and push a new branch on top of a base branch", + ) + branch_open.add_argument( + "name", + nargs="?", + help="Name of the new branch (optional; will be asked interactively if omitted)", + ) + branch_open.add_argument( + "--base", + default="main", + help="Base branch to create the new branch from (default: main)", + ) + + # ------------------------------------------------------------ # release # ------------------------------------------------------------ @@ -306,8 +335,6 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: ) add_identifier_arguments(version_parser) - - # ------------------------------------------------------------ # changelog # ------------------------------------------------------------ diff --git a/tests/e2e/test_integration_branch_commands.py b/tests/e2e/test_integration_branch_commands.py new file mode 100644 index 0000000..837fd7a --- /dev/null +++ b/tests/e2e/test_integration_branch_commands.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import runpy +import sys +import unittest +from unittest.mock import patch + + +class TestIntegrationBranchCommands(unittest.TestCase): + """ + E2E-style tests for the 'pkgmgr branch' CLI wiring. + + We do NOT call real git; instead we patch pkgmgr.branch_commands.open_branch + and verify that the CLI invokes it with the correct parameters. + """ + + def _run_pkgmgr(self, argv: list[str]) -> None: + """ + Helper to run 'pkgmgr' via its entry module with a given argv. + """ + original_argv = list(sys.argv) + try: + # argv typically looks like: ["pkgmgr", "branch", ...] + sys.argv = argv + # Run the CLI entry point + runpy.run_module("pkgmgr.cli", run_name="__main__") + finally: + sys.argv = original_argv + + @patch("pkgmgr.branch_commands.open_branch") + def test_branch_open_with_name_and_base(self, mock_open_branch) -> None: + """ + pkgmgr branch open feature/test --base develop + should invoke open_branch(name='feature/test', base_branch='develop', cwd='.') + """ + self._run_pkgmgr( + ["pkgmgr", "branch", "open", "feature/test", "--base", "develop"] + ) + + mock_open_branch.assert_called_once() + _, kwargs = mock_open_branch.call_args + self.assertEqual(kwargs.get("name"), "feature/test") + self.assertEqual(kwargs.get("base_branch"), "develop") + self.assertEqual(kwargs.get("cwd"), ".") + + @patch("pkgmgr.branch_commands.open_branch") + def test_branch_open_without_name_uses_default_base(self, mock_open_branch) -> None: + """ + pkgmgr branch open + should invoke open_branch(name=None, base_branch='main', cwd='.') + (the branch name will be asked interactively inside open_branch). + """ + self._run_pkgmgr(["pkgmgr", "branch", "open"]) + + mock_open_branch.assert_called_once() + _, kwargs = mock_open_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/unit/pkgmgr/test_branch_commands.py b/tests/unit/pkgmgr/test_branch_commands.py new file mode 100644 index 0000000..68df955 --- /dev/null +++ b/tests/unit/pkgmgr/test_branch_commands.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from pkgmgr.branch_commands import open_branch +from pkgmgr.git_utils import GitError + + +class TestOpenBranch(unittest.TestCase): + @patch("pkgmgr.branch_commands.run_git") + def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None: + """ + open_branch(name, base='main') should: + - fetch origin + - checkout base + - pull base + - create new branch + - push with upstream + """ + mock_run_git.return_value = "" + + open_branch(name="feature/test", base_branch="main", cwd="/repo") + + # We expect a specific sequence of Git calls. + expected_calls = [ + (["fetch", "origin"], "/repo"), + (["checkout", "main"], "/repo"), + (["pull", "origin", "main"], "/repo"), + (["checkout", "-b", "feature/test"], "/repo"), + (["push", "-u", "origin", "feature/test"], "/repo"), + ] + + self.assertEqual(mock_run_git.call_count, len(expected_calls)) + + for call, (args_expected, cwd_expected) in zip( + mock_run_git.call_args_list, expected_calls + ): + args, kwargs = call + self.assertEqual(args[0], args_expected) + self.assertEqual(kwargs.get("cwd"), cwd_expected) + + @patch("builtins.input", return_value="feature/interactive") + @patch("pkgmgr.branch_commands.run_git") + def test_open_branch_prompts_for_name_if_missing( + self, + mock_run_git, + mock_input, + ) -> None: + """ + If name is None/empty, open_branch should prompt via input() + and still perform the full Git sequence. + """ + mock_run_git.return_value = "" + + open_branch(name=None, base_branch="develop", cwd="/repo") + + # Ensure we asked for input exactly once + mock_input.assert_called_once() + + expected_calls = [ + (["fetch", "origin"], "/repo"), + (["checkout", "develop"], "/repo"), + (["pull", "origin", "develop"], "/repo"), + (["checkout", "-b", "feature/interactive"], "/repo"), + (["push", "-u", "origin", "feature/interactive"], "/repo"), + ] + + self.assertEqual(mock_run_git.call_count, len(expected_calls)) + for call, (args_expected, cwd_expected) in zip( + mock_run_git.call_args_list, expected_calls + ): + args, kwargs = call + self.assertEqual(args[0], args_expected) + self.assertEqual(kwargs.get("cwd"), cwd_expected) + + @patch("pkgmgr.branch_commands.run_git") + def test_open_branch_raises_runtimeerror_on_git_failure(self, mock_run_git) -> None: + """ + If a GitError occurs (e.g. fetch fails), open_branch should + raise a RuntimeError with a helpful message. + """ + + def side_effect(args, cwd="."): + # Simulate a failure on the first call (fetch) + raise GitError("simulated fetch failure") + + mock_run_git.side_effect = side_effect + + with self.assertRaises(RuntimeError) as cm: + open_branch(name="feature/fail", base_branch="main", cwd="/repo") + + msg = str(cm.exception) + self.assertIn("Failed to fetch from origin", msg) + self.assertIn("simulated fetch failure", msg) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/test_cli_branch.py b/tests/unit/pkgmgr/test_cli_branch.py new file mode 100644 index 0000000..f58ab9d --- /dev/null +++ b/tests/unit/pkgmgr/test_cli_branch.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from pkgmgr.cli_core.commands.branch import handle_branch +from pkgmgr.cli_core.context import CLIContext + + +class TestCliBranch(unittest.TestCase): + def _dummy_ctx(self) -> CLIContext: + """ + Minimal CLIContext; handle_branch does not actually use it, + but we keep the type consistent. + """ + return CLIContext( + config_merged={}, + repositories_base_dir="/tmp/repos", + all_repositories=[], + binaries_dir="/tmp/bin", + user_config_path="/tmp/config.yaml", + ) + + @patch("pkgmgr.cli_core.commands.branch.open_branch") + def test_handle_branch_open_forwards_args_to_open_branch(self, mock_open_branch) -> None: + """ + handle_branch('open') should call open_branch with name, base and cwd='.'. + """ + args = SimpleNamespace( + command="branch", + subcommand="open", + name="feature/cli-test", + base="develop", + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_open_branch.assert_called_once() + call_args, call_kwargs = mock_open_branch.call_args + self.assertEqual(call_kwargs.get("name"), "feature/cli-test") + self.assertEqual(call_kwargs.get("base_branch"), "develop") + self.assertEqual(call_kwargs.get("cwd"), ".") + + @patch("pkgmgr.cli_core.commands.branch.open_branch") + def test_handle_branch_open_uses_default_base_when_not_set(self, mock_open_branch) -> None: + """ + If --base is not passed, argparse gives base='main' (default), + and handle_branch should propagate that to open_branch. + """ + args = SimpleNamespace( + command="branch", + subcommand="open", + name=None, + base="main", + ) + + ctx = self._dummy_ctx() + handle_branch(args, ctx) + + mock_open_branch.assert_called_once() + _, call_kwargs = mock_open_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). + """ + args = SimpleNamespace( + command="branch", + subcommand="unknown", + ) + ctx = self._dummy_ctx() + + with self.assertRaises(SystemExit) as cm: + handle_branch(args, ctx) + + self.assertEqual(cm.exception.code, 2) + + +if __name__ == "__main__": + unittest.main()