diff --git a/src/pkgmgr/actions/branch/__init__.py b/src/pkgmgr/actions/branch/__init__.py index 918e388..200cab3 100644 --- a/src/pkgmgr/actions/branch/__init__.py +++ b/src/pkgmgr/actions/branch/__init__.py @@ -1,235 +1,14 @@ -# pkgmgr/actions/branch/__init__.py -#!/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.commands.branch) stays thin and testable. +Public API for branch actions. """ -from __future__ import annotations +from .open_branch import open_branch +from .close_branch import close_branch +from .drop_branch import drop_branch -from typing import Optional - -from pkgmgr.core.git import run_git, GitError, get_current_branch - - -# --------------------------------------------------------------------------- -# Branch creation (open) -# --------------------------------------------------------------------------- - -def open_branch( - name: Optional[str], - base_branch: str = "main", - fallback_base: str = "master", - cwd: str = ".", -) -> None: - """ - Create and push a new feature branch on top of a base branch. - - The base branch is resolved by: - 1. Trying 'base_branch' (default: 'main') - 2. Falling back to 'fallback_base' (default: 'master') - - 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 to enter one. - """ - - # Request name interactively if not provided - if not name: - name = input("Enter new branch name: ").strip() - - if not name: - raise RuntimeError("Branch name must not be empty.") - - # Resolve which base branch to use (main or master) - resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd) - - # 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", resolved_base], cwd=cwd) - except GitError as exc: - raise RuntimeError( - f"Failed to checkout base branch {resolved_base!r}: {exc}" - ) from exc - - # 3) Pull latest changes for base branch - try: - run_git(["pull", "origin", resolved_base], cwd=cwd) - except GitError as exc: - raise RuntimeError( - f"Failed to pull latest changes for base branch {resolved_base!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 {resolved_base!r}: {exc}" - ) from exc - - # 5) Push new branch to origin - 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 - - -# --------------------------------------------------------------------------- -# Base branch resolver (shared by open/close) -# --------------------------------------------------------------------------- - -def _resolve_base_branch( - preferred: str, - fallback: str, - cwd: str, -) -> str: - """ - Resolve the base branch to use. - - Try `preferred` first (default: main), - fall back to `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." - ) - - -# --------------------------------------------------------------------------- -# Branch closing (merge + deletion) -# --------------------------------------------------------------------------- - -def close_branch( - name: Optional[str], - base_branch: str = "main", - fallback_base: str = "master", - cwd: str = ".", -) -> None: - """ - Merge a feature branch into the base branch and delete it afterwards. - - Steps: - 1) Determine the branch name (argument or current branch) - 2) Resolve base branch (main/master) - 3) Ask for confirmation - 4) git fetch origin - 5) git checkout - 6) git pull origin - 7) git merge --no-ff - 8) git push origin - 9) Delete branch locally - 10) Delete branch on origin (best effort) - """ - - # 1) Determine which branch should be closed - 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 - 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) Ask user for confirmation - 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 - 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 state - 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 the feature branch - 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} after merge: {exc}" - ) from exc - - # 9) Delete branch locally - try: - run_git(["branch", "-d", name], cwd=cwd) - except GitError as exc: - raise RuntimeError( - f"Failed to delete local branch {name!r}: {exc}" - ) from exc - - # 10) Delete branch on origin (best effort) - try: - run_git(["push", "origin", "--delete", name], cwd=cwd) - except GitError as exc: - raise RuntimeError( - f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}" - ) from exc +__all__ = [ + "open_branch", + "close_branch", + "drop_branch", +] diff --git a/src/pkgmgr/actions/branch/close_branch.py b/src/pkgmgr/actions/branch/close_branch.py new file mode 100644 index 0000000..ffe5c2a --- /dev/null +++ b/src/pkgmgr/actions/branch/close_branch.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Optional +from pkgmgr.core.git import run_git, GitError, get_current_branch +from .utils import _resolve_base_branch + + +def close_branch( + name: Optional[str], + base_branch: str = "main", + fallback_base: str = "master", + cwd: str = ".", + force: bool = False, +) -> None: + """ + Merge a feature branch into the base branch and delete it afterwards. + """ + + # Determine branch name + 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.") + + 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." + ) + + # Confirmation + if not force: + answer = input( + f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): " + ).strip().lower() + if answer != "y": + print("Aborted closing branch.") + return + + # Fetch + 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 + + # Checkout base + 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 + + # Pull latest + 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 + + # Merge + 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 + + # Push result + try: + run_git(["push", "origin", target_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to push base branch {target_base!r} after merge: {exc}" + ) from exc + + # Delete local + try: + run_git(["branch", "-d", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to delete local branch {name!r}: {exc}" + ) from exc + + # Delete remote + try: + run_git(["push", "origin", "--delete", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Branch {name!r} deleted locally, but remote deletion failed: {exc}" + ) from exc diff --git a/src/pkgmgr/actions/branch/drop_branch.py b/src/pkgmgr/actions/branch/drop_branch.py new file mode 100644 index 0000000..0ec9416 --- /dev/null +++ b/src/pkgmgr/actions/branch/drop_branch.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Optional +from pkgmgr.core.git import run_git, GitError, get_current_branch +from .utils import _resolve_base_branch + + +def drop_branch( + name: Optional[str], + base_branch: str = "main", + fallback_base: str = "master", + cwd: str = ".", + force: bool = False, +) -> None: + """ + Delete a branch locally and remotely without merging. + """ + + 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.") + + target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd) + + if name == target_base: + raise RuntimeError( + f"Refusing to drop base branch {target_base!r}. It cannot be deleted." + ) + + # Confirmation + if not force: + answer = input( + f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): " + ).strip().lower() + if answer != "y": + print("Aborted dropping branch.") + return + + # Local delete + try: + run_git(["branch", "-d", name], cwd=cwd) + except GitError as exc: + raise RuntimeError(f"Failed to delete local branch {name!r}: {exc}") from exc + + # Remote delete + try: + run_git(["push", "origin", "--delete", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}" + ) from exc diff --git a/src/pkgmgr/actions/branch/open_branch.py b/src/pkgmgr/actions/branch/open_branch.py new file mode 100644 index 0000000..bac12e0 --- /dev/null +++ b/src/pkgmgr/actions/branch/open_branch.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Optional +from pkgmgr.core.git import run_git, GitError +from .utils import _resolve_base_branch + + +def open_branch( + name: Optional[str], + base_branch: str = "main", + fallback_base: str = "master", + cwd: str = ".", +) -> None: + """ + Create and push a new feature branch on top of a base branch. + """ + + # Request name interactively if not provided + if not name: + name = input("Enter new branch name: ").strip() + + if not name: + raise RuntimeError("Branch name must not be empty.") + + resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd) + + # 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", resolved_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to checkout base branch {resolved_base!r}: {exc}" + ) from exc + + # 3) Pull latest changes + try: + run_git(["pull", "origin", resolved_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to pull latest changes for base branch {resolved_base!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 {resolved_base!r}: {exc}" + ) from exc + + # 5) Push new branch + 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/src/pkgmgr/actions/branch/utils.py b/src/pkgmgr/actions/branch/utils.py new file mode 100644 index 0000000..c37c1e1 --- /dev/null +++ b/src/pkgmgr/actions/branch/utils.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from pkgmgr.core.git import run_git, GitError + + +def _resolve_base_branch( + preferred: str, + fallback: str, + cwd: str, +) -> str: + """ + Resolve the base branch to use. + + Try `preferred` first (default: main), + fall back to `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." + ) diff --git a/src/pkgmgr/cli/commands/branch.py b/src/pkgmgr/cli/commands/branch.py index f67ff81..3838bab 100644 --- a/src/pkgmgr/cli/commands/branch.py +++ b/src/pkgmgr/cli/commands/branch.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys from pkgmgr.cli.context import CLIContext -from pkgmgr.actions.branch import open_branch, close_branch +from pkgmgr.actions.branch import open_branch, close_branch, drop_branch def handle_branch(args, ctx: CLIContext) -> None: @@ -12,7 +12,8 @@ def handle_branch(args, ctx: CLIContext) -> None: Currently supported: - pkgmgr branch open [] [--base ] - - pkgmgr branch close [] [--base ] + - pkgmgr branch close [] [--base ] [--force|-f] + - pkgmgr branch drop [] [--base ] [--force|-f] """ if args.subcommand == "open": open_branch( @@ -27,6 +28,16 @@ def handle_branch(args, ctx: CLIContext) -> None: name=getattr(args, "name", None), base_branch=getattr(args, "base", "main"), cwd=".", + force=getattr(args, "force", False), + ) + return + + if args.subcommand == "drop": + drop_branch( + name=getattr(args, "name", None), + base_branch=getattr(args, "base", "main"), + cwd=".", + force=getattr(args, "force", False), ) return diff --git a/src/pkgmgr/cli/parser/branch_cmd.py b/src/pkgmgr/cli/parser/branch_cmd.py index e54af75..1a181a1 100644 --- a/src/pkgmgr/cli/parser/branch_cmd.py +++ b/src/pkgmgr/cli/parser/branch_cmd.py @@ -14,7 +14,7 @@ def add_branch_subparsers( """ branch_parser = subparsers.add_parser( "branch", - help="Branch-related utilities (e.g. open/close feature branches)", + help="Branch-related utilities (e.g. open/close/drop feature branches)", ) branch_subparsers = branch_parser.add_subparsers( dest="subcommand", @@ -22,6 +22,9 @@ def add_branch_subparsers( required=True, ) + # ----------------------------------------------------------------------- + # branch open + # ----------------------------------------------------------------------- branch_open = branch_subparsers.add_parser( "open", help="Create and push a new branch on top of a base branch", @@ -40,6 +43,9 @@ def add_branch_subparsers( help="Base branch to create the new branch from (default: main)", ) + # ----------------------------------------------------------------------- + # branch close + # ----------------------------------------------------------------------- branch_close = branch_subparsers.add_parser( "close", help="Merge a feature branch into base and delete it", @@ -60,3 +66,39 @@ def add_branch_subparsers( "internally if main does not exist)" ), ) + branch_close.add_argument( + "-f", + "--force", + action="store_true", + help="Skip confirmation prompt and close the branch directly", + ) + + # ----------------------------------------------------------------------- + # branch drop + # ----------------------------------------------------------------------- + branch_drop = branch_subparsers.add_parser( + "drop", + help="Delete a branch locally and on origin (without merging)", + ) + branch_drop.add_argument( + "name", + nargs="?", + help=( + "Name of the branch to drop (optional; current branch is used " + "if omitted)" + ), + ) + branch_drop.add_argument( + "--base", + default="main", + help=( + "Base branch used to protect main/master from deletion " + "(default: main; falls back to master internally)" + ), + ) + branch_drop.add_argument( + "-f", + "--force", + action="store_true", + help="Skip confirmation prompt and drop the branch directly", + ) diff --git a/tests/e2e/test_branch_help.py b/tests/e2e/test_branch_help.py new file mode 100644 index 0000000..910b3ef --- /dev/null +++ b/tests/e2e/test_branch_help.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import io +import runpy +import sys +import unittest +from contextlib import redirect_stdout, redirect_stderr + + +def _run_pkgmgr_help(argv_tail: list[str]) -> str: + """ + Run `pkgmgr --help` via the main module and return captured output. + + argparse parses sys.argv[1:], so argv[0] must be a dummy program name. + Any SystemExit with code 0 or None is treated as success. + """ + original_argv = list(sys.argv) + buffer = io.StringIO() + cmd_repr = "pkgmgr " + " ".join(argv_tail) + " --help" + + try: + # IMPORTANT: argv[0] must be a dummy program name + sys.argv = ["pkgmgr"] + list(argv_tail) + ["--help"] + + try: + with redirect_stdout(buffer), redirect_stderr(buffer): + runpy.run_module("main", run_name="__main__") + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else None + if code not in (0, None): + raise AssertionError( + f"{cmd_repr!r} failed with exit code {exc.code}." + ) from exc + + return buffer.getvalue() + finally: + sys.argv = original_argv + + +class TestBranchHelpE2E(unittest.TestCase): + """ + End-to-end tests ensuring that `pkgmgr branch` help commands + run without error and print usage information. + """ + + def test_branch_root_help(self) -> None: + """ + `pkgmgr branch --help` should run without error. + """ + output = _run_pkgmgr_help(["branch"]) + self.assertIn("usage:", output) + self.assertIn("pkgmgr branch", output) + + def test_branch_open_help(self) -> None: + """ + `pkgmgr branch open --help` should run without error. + """ + output = _run_pkgmgr_help(["branch", "open"]) + self.assertIn("usage:", output) + self.assertIn("branch open", output) + + def test_branch_close_help(self) -> None: + """ + `pkgmgr branch close --help` should run without error. + """ + output = _run_pkgmgr_help(["branch", "close"]) + self.assertIn("usage:", output) + self.assertIn("branch close", output) + + def test_branch_drop_help(self) -> None: + """ + `pkgmgr branch drop --help` should run without error. + """ + output = _run_pkgmgr_help(["branch", "drop"]) + self.assertIn("usage:", output) + self.assertIn("branch drop", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_branch_cli.py b/tests/integration/test_branch_cli.py new file mode 100644 index 0000000..2b1856a --- /dev/null +++ b/tests/integration/test_branch_cli.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Integration tests for the `pkgmgr branch` CLI wiring. + +These tests verify that: + - The argument parser creates the correct structure for + `branch open`, `branch close` and `branch drop`. + - `handle_branch` calls the corresponding helper functions + with the expected arguments (including base branch, cwd and force). +""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.cli.parser import create_parser +from pkgmgr.cli.commands.branch import handle_branch + + +class TestBranchCLI(unittest.TestCase): + """ + Tests for the branch subcommands implemented in the CLI. + """ + + def _create_parser(self): + """ + Create the top-level parser with a minimal description. + """ + return create_parser("pkgmgr test parser") + + # --------------------------------------------------------------------- # + # branch open + # --------------------------------------------------------------------- # + + @patch("pkgmgr.cli.commands.branch.open_branch") + def test_branch_open_with_name_and_base(self, mock_open_branch): + """ + Ensure that `pkgmgr branch open --base ` calls + open_branch() with the correct parameters. + """ + parser = self._create_parser() + args = parser.parse_args( + ["branch", "open", "feature/test-branch", "--base", "develop"] + ) + + # Sanity check: parser wiring + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "open") + self.assertEqual(args.name, "feature/test-branch") + self.assertEqual(args.base, "develop") + + # ctx is currently unused by handle_branch, so we can pass None + handle_branch(args, ctx=None) + + mock_open_branch.assert_called_once() + _args, kwargs = mock_open_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/test-branch") + self.assertEqual(kwargs.get("base_branch"), "develop") + self.assertEqual(kwargs.get("cwd"), ".") + + @patch("pkgmgr.cli.commands.branch.open_branch") + def test_branch_open_with_name_and_default_base(self, mock_open_branch): + """ + Ensure that `pkgmgr branch open ` without --base uses + the default base branch 'main'. + """ + parser = self._create_parser() + args = parser.parse_args(["branch", "open", "feature/default-base"]) + + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "open") + self.assertEqual(args.name, "feature/default-base") + self.assertEqual(args.base, "main") + + handle_branch(args, ctx=None) + + mock_open_branch.assert_called_once() + _args, kwargs = mock_open_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/default-base") + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + + # --------------------------------------------------------------------- # + # branch close + # --------------------------------------------------------------------- # + + @patch("pkgmgr.cli.commands.branch.close_branch") + def test_branch_close_with_name_and_base(self, mock_close_branch): + """ + Ensure that `pkgmgr branch close --base ` calls + close_branch() with the correct parameters and force=False by default. + """ + parser = self._create_parser() + args = parser.parse_args( + ["branch", "close", "feature/old-branch", "--base", "main"] + ) + + # Sanity check: parser wiring + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "close") + self.assertEqual(args.name, "feature/old-branch") + self.assertEqual(args.base, "main") + self.assertFalse(args.force) + + handle_branch(args, ctx=None) + + mock_close_branch.assert_called_once() + _args, kwargs = mock_close_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/old-branch") + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertFalse(kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.close_branch") + def test_branch_close_without_name_uses_none(self, mock_close_branch): + """ + Ensure that `pkgmgr branch close` without a name passes name=None + into close_branch(), leaving branch resolution to the helper. + """ + parser = self._create_parser() + args = parser.parse_args(["branch", "close"]) + + # Parser wiring: no name → None + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "close") + self.assertIsNone(args.name) + self.assertEqual(args.base, "main") + self.assertFalse(args.force) + + handle_branch(args, ctx=None) + + mock_close_branch.assert_called_once() + _args, kwargs = mock_close_branch.call_args + + self.assertIsNone(kwargs.get("name")) + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertFalse(kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.close_branch") + def test_branch_close_with_force(self, mock_close_branch): + """ + Ensure that `pkgmgr branch close --force` passes force=True. + """ + parser = self._create_parser() + args = parser.parse_args( + ["branch", "close", "feature/old-branch", "--base", "main", "--force"] + ) + + self.assertTrue(args.force) + + handle_branch(args, ctx=None) + + mock_close_branch.assert_called_once() + _args, kwargs = mock_close_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/old-branch") + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertTrue(kwargs.get("force")) + + # --------------------------------------------------------------------- # + # branch drop + # --------------------------------------------------------------------- # + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_branch_drop_with_name_and_base(self, mock_drop_branch): + """ + Ensure that `pkgmgr branch drop --base ` calls + drop_branch() with the correct parameters and force=False by default. + """ + parser = self._create_parser() + args = parser.parse_args( + ["branch", "drop", "feature/tmp-branch", "--base", "develop"] + ) + + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "drop") + self.assertEqual(args.name, "feature/tmp-branch") + self.assertEqual(args.base, "develop") + self.assertFalse(args.force) + + handle_branch(args, ctx=None) + + mock_drop_branch.assert_called_once() + _args, kwargs = mock_drop_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/tmp-branch") + self.assertEqual(kwargs.get("base_branch"), "develop") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertFalse(kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_branch_drop_without_name(self, mock_drop_branch): + """ + Ensure that `pkgmgr branch drop` without a name passes name=None + into drop_branch(), leaving branch resolution to the helper. + """ + parser = self._create_parser() + args = parser.parse_args(["branch", "drop"]) + + self.assertEqual(args.command, "branch") + self.assertEqual(args.subcommand, "drop") + self.assertIsNone(args.name) + self.assertEqual(args.base, "main") + self.assertFalse(args.force) + + handle_branch(args, ctx=None) + + mock_drop_branch.assert_called_once() + _args, kwargs = mock_drop_branch.call_args + + self.assertIsNone(kwargs.get("name")) + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertFalse(kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_branch_drop_with_force(self, mock_drop_branch): + """ + Ensure that `pkgmgr branch drop --force` passes force=True. + """ + parser = self._create_parser() + args = parser.parse_args( + ["branch", "drop", "feature/tmp-branch", "--force"] + ) + + self.assertTrue(args.force) + + handle_branch(args, ctx=None) + + mock_drop_branch.assert_called_once() + _args, kwargs = mock_drop_branch.call_args + + self.assertEqual(kwargs.get("name"), "feature/tmp-branch") + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + self.assertTrue(kwargs.get("force")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/branch/__init__.py b/tests/unit/pkgmgr/actions/branch/__init__.py new file mode 100644 index 0000000..f2c6360 --- /dev/null +++ b/tests/unit/pkgmgr/actions/branch/__init__.py @@ -0,0 +1,33 @@ +import unittest +from unittest.mock import patch, MagicMock + +from pkgmgr.actions.branch.utils import _resolve_base_branch +from pkgmgr.core.git import GitError + + +class TestResolveBaseBranch(unittest.TestCase): + @patch("pkgmgr.actions.branch.utils.run_git") + def test_resolves_preferred(self, run_git): + run_git.return_value = None + result = _resolve_base_branch("main", "master", cwd=".") + self.assertEqual(result, "main") + run_git.assert_called_with(["rev-parse", "--verify", "main"], cwd=".") + + @patch("pkgmgr.actions.branch.utils.run_git") + def test_resolves_fallback(self, run_git): + run_git.side_effect = [ + GitError("main missing"), + None, + ] + result = _resolve_base_branch("main", "master", cwd=".") + self.assertEqual(result, "master") + + @patch("pkgmgr.actions.branch.utils.run_git") + def test_raises_when_no_branch_exists(self, run_git): + run_git.side_effect = GitError("missing") + with self.assertRaises(RuntimeError): + _resolve_base_branch("main", "master", cwd=".") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/branch/test_close_branch.py b/tests/unit/pkgmgr/actions/branch/test_close_branch.py new file mode 100644 index 0000000..37a352a --- /dev/null +++ b/tests/unit/pkgmgr/actions/branch/test_close_branch.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import patch, MagicMock + +from pkgmgr.actions.branch.close_branch import close_branch +from pkgmgr.core.git import GitError + + +class TestCloseBranch(unittest.TestCase): + @patch("pkgmgr.actions.branch.close_branch.input", return_value="y") + @patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.close_branch.run_git") + def test_close_branch_happy_path(self, run_git, resolve, current, input_mock): + close_branch(None, cwd=".") + expected = [ + (["fetch", "origin"],), + (["checkout", "main"],), + (["pull", "origin", "main"],), + (["merge", "--no-ff", "feature-x"],), + (["push", "origin", "main"],), + (["branch", "-d", "feature-x"],), + (["push", "origin", "--delete", "feature-x"],), + ] + actual = [call.args for call in run_git.call_args_list] + self.assertEqual(actual, expected) + + @patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main") + @patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main") + def test_refuses_to_close_base_branch(self, resolve, current): + with self.assertRaises(RuntimeError): + close_branch(None) + + @patch("pkgmgr.actions.branch.close_branch.input", return_value="n") + @patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.close_branch.run_git") + def test_close_branch_aborts_on_no(self, run_git, resolve, current, input_mock): + close_branch(None, cwd=".") + run_git.assert_not_called() + + @patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.close_branch.run_git") + def test_close_branch_force_skips_prompt(self, run_git, resolve, current): + close_branch(None, cwd=".", force=True) + self.assertGreater(len(run_git.call_args_list), 0) + + @patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitError("fail")) + def test_close_branch_errors_if_cannot_detect_branch(self, current): + with self.assertRaises(RuntimeError): + close_branch(None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/branch/test_drop_branch.py b/tests/unit/pkgmgr/actions/branch/test_drop_branch.py new file mode 100644 index 0000000..5f51708 --- /dev/null +++ b/tests/unit/pkgmgr/actions/branch/test_drop_branch.py @@ -0,0 +1,50 @@ +import unittest +from unittest.mock import patch, MagicMock + +from pkgmgr.actions.branch.drop_branch import drop_branch +from pkgmgr.core.git import GitError + + +class TestDropBranch(unittest.TestCase): + @patch("pkgmgr.actions.branch.drop_branch.input", return_value="y") + @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.drop_branch.run_git") + def test_drop_branch_happy_path(self, run_git, resolve, current, input_mock): + drop_branch(None, cwd=".") + expected = [ + (["branch", "-d", "feature-x"],), + (["push", "origin", "--delete", "feature-x"],), + ] + actual = [call.args for call in run_git.call_args_list] + self.assertEqual(actual, expected) + + @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main") + @patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main") + def test_refuses_to_drop_base_branch(self, resolve, current): + with self.assertRaises(RuntimeError): + drop_branch(None) + + @patch("pkgmgr.actions.branch.drop_branch.input", return_value="n") + @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.drop_branch.run_git") + def test_drop_branch_aborts_on_no(self, run_git, resolve, current, input_mock): + drop_branch(None, cwd=".") + run_git.assert_not_called() + + @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x") + @patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.drop_branch.run_git") + def test_drop_branch_force_skips_prompt(self, run_git, resolve, current): + drop_branch(None, cwd=".", force=True) + self.assertGreater(len(run_git.call_args_list), 0) + + @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitError("fail")) + def test_drop_branch_errors_if_no_branch_detected(self, current): + with self.assertRaises(RuntimeError): + drop_branch(None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/branch/test_open_branch.py b/tests/unit/pkgmgr/actions/branch/test_open_branch.py new file mode 100644 index 0000000..d1b848b --- /dev/null +++ b/tests/unit/pkgmgr/actions/branch/test_open_branch.py @@ -0,0 +1,37 @@ +import unittest +from unittest.mock import patch, MagicMock + +from pkgmgr.actions.branch.open_branch import open_branch + + +class TestOpenBranch(unittest.TestCase): + @patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.open_branch.run_git") + def test_open_branch_executes_git_commands(self, run_git, resolve): + open_branch("feature-x", base_branch="main", cwd=".") + expected_calls = [ + (["fetch", "origin"],), + (["checkout", "main"],), + (["pull", "origin", "main"],), + (["checkout", "-b", "feature-x"],), + (["push", "-u", "origin", "feature-x"],), + ] + actual = [call.args for call in run_git.call_args_list] + self.assertEqual(actual, expected_calls) + + @patch("builtins.input", return_value="auto-branch") + @patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main") + @patch("pkgmgr.actions.branch.open_branch.run_git") + def test_open_branch_prompts_for_name(self, run_git, resolve, input_mock): + open_branch(None) + calls = [call.args for call in run_git.call_args_list] + self.assertEqual(calls[3][0][0], "checkout") # verify git executed normally + + def test_open_branch_rejects_empty_name(self): + with patch("builtins.input", return_value=""): + with self.assertRaises(RuntimeError): + open_branch(None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/branch/test_utils.py b/tests/unit/pkgmgr/actions/branch/test_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/test_branch.py b/tests/unit/pkgmgr/actions/test_branch.py deleted file mode 100644 index e6f6533..0000000 --- a/tests/unit/pkgmgr/actions/test_branch.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest.mock import patch - -from pkgmgr.actions.branch import open_branch -from pkgmgr.core.git import GitError - - -class TestOpenBranch(unittest.TestCase): - @patch("pkgmgr.actions.branch.run_git") - def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None: - """ - open_branch(name, base='main') should: - - resolve base branch (prefers 'main', falls back to 'master') - - fetch origin - - checkout resolved base - - pull resolved 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 = [ - (["rev-parse", "--verify", "main"], "/repo"), - (["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.actions.branch.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 on the resolved base. - """ - 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 = [ - (["rev-parse", "--verify", "develop"], "/repo"), - (["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.actions.branch.run_git") - def test_open_branch_raises_runtimeerror_on_fetch_failure(self, mock_run_git) -> None: - """ - If a GitError occurs on fetch, open_branch should raise a RuntimeError - with a helpful message. - """ - - def side_effect(args, cwd="."): - # First call: base resolution (rev-parse) should succeed - if args == ["rev-parse", "--verify", "main"]: - return "" - # Second call: fetch should fail - if args == ["fetch", "origin"]: - raise GitError("simulated fetch failure") - return "" - - 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) - - @patch("pkgmgr.actions.branch.run_git") - def test_open_branch_uses_fallback_master_if_main_missing(self, mock_run_git) -> None: - """ - If the preferred base (e.g. 'main') does not exist, open_branch should - fall back to the fallback base (default: 'master'). - """ - - def side_effect(args, cwd="."): - # First: rev-parse main -> fails - if args == ["rev-parse", "--verify", "main"]: - raise GitError("main does not exist") - # Second: rev-parse master -> succeeds - if args == ["rev-parse", "--verify", "master"]: - return "" - # Then normal flow on master - return "" - - mock_run_git.side_effect = side_effect - - open_branch(name="feature/fallback", base_branch="main", cwd="/repo") - - expected_calls = [ - (["rev-parse", "--verify", "main"], "/repo"), - (["rev-parse", "--verify", "master"], "/repo"), - (["fetch", "origin"], "/repo"), - (["checkout", "master"], "/repo"), - (["pull", "origin", "master"], "/repo"), - (["checkout", "-b", "feature/fallback"], "/repo"), - (["push", "-u", "origin", "feature/fallback"], "/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) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/pkgmgr/cli/test_branch_cli.py b/tests/unit/pkgmgr/cli/test_branch_cli.py deleted file mode 100644 index a0e67be..0000000 --- a/tests/unit/pkgmgr/cli/test_branch_cli.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Unit tests for the `pkgmgr branch` CLI wiring. - -These tests verify that: - - The argument parser creates the correct structure for - `branch open` and `branch close`. - - `handle_branch` calls the corresponding helper functions - with the expected arguments (including base branch and cwd). -""" - -from __future__ import annotations - -import unittest -from unittest.mock import patch - -from pkgmgr.cli.parser import create_parser -from pkgmgr.cli.commands.branch import handle_branch - - -class TestBranchCLI(unittest.TestCase): - """ - Tests for the branch subcommands implemented in cli. - """ - - def _create_parser(self): - """ - Create the top-level parser with a minimal description. - """ - return create_parser("pkgmgr test parser") - - @patch("pkgmgr.cli.commands.branch.open_branch") - def test_branch_open_with_name_and_base(self, mock_open_branch): - """ - Ensure that `pkgmgr branch open --base ` calls - open_branch() with the correct parameters. - """ - parser = self._create_parser() - args = parser.parse_args( - ["branch", "open", "feature/test-branch", "--base", "develop"] - ) - - # Sanity check: parser wiring - self.assertEqual(args.command, "branch") - self.assertEqual(args.subcommand, "open") - self.assertEqual(args.name, "feature/test-branch") - self.assertEqual(args.base, "develop") - - # ctx is currently unused by handle_branch, so we can pass None - handle_branch(args, ctx=None) - - mock_open_branch.assert_called_once() - _args, kwargs = mock_open_branch.call_args - - self.assertEqual(kwargs.get("name"), "feature/test-branch") - self.assertEqual(kwargs.get("base_branch"), "develop") - self.assertEqual(kwargs.get("cwd"), ".") - - @patch("pkgmgr.cli.commands.branch.close_branch") - def test_branch_close_with_name_and_base(self, mock_close_branch): - """ - Ensure that `pkgmgr branch close --base ` calls - close_branch() with the correct parameters. - """ - parser = self._create_parser() - args = parser.parse_args( - ["branch", "close", "feature/old-branch", "--base", "main"] - ) - - # Sanity check: parser wiring - self.assertEqual(args.command, "branch") - self.assertEqual(args.subcommand, "close") - self.assertEqual(args.name, "feature/old-branch") - self.assertEqual(args.base, "main") - - handle_branch(args, ctx=None) - - mock_close_branch.assert_called_once() - _args, kwargs = mock_close_branch.call_args - - self.assertEqual(kwargs.get("name"), "feature/old-branch") - self.assertEqual(kwargs.get("base_branch"), "main") - self.assertEqual(kwargs.get("cwd"), ".") - - @patch("pkgmgr.cli.commands.branch.close_branch") - def test_branch_close_without_name_uses_none(self, mock_close_branch): - """ - Ensure that `pkgmgr branch close` without a name passes name=None - into close_branch(), leaving branch resolution to the helper. - """ - parser = self._create_parser() - args = parser.parse_args(["branch", "close"]) - - # Parser wiring: no name → None - self.assertEqual(args.command, "branch") - self.assertEqual(args.subcommand, "close") - self.assertIsNone(args.name) - - handle_branch(args, ctx=None) - - mock_close_branch.assert_called_once() - _args, 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/unit/pkgmgr/cli/test_cli_branch.py b/tests/unit/pkgmgr/cli/test_handle_branch.py similarity index 51% rename from tests/unit/pkgmgr/cli/test_cli_branch.py rename to tests/unit/pkgmgr/cli/test_handle_branch.py index 23a2d84..bed6da9 100644 --- a/tests/unit/pkgmgr/cli/test_cli_branch.py +++ b/tests/unit/pkgmgr/cli/test_handle_branch.py @@ -22,6 +22,10 @@ class TestCliBranch(unittest.TestCase): user_config_path="/tmp/config.yaml", ) + # ------------------------------------------------------------------ + # open subcommand + # ------------------------------------------------------------------ + @patch("pkgmgr.cli.commands.branch.open_branch") def test_handle_branch_open_forwards_args_to_open_branch(self, mock_open_branch) -> None: """ @@ -73,13 +77,15 @@ class TestCliBranch(unittest.TestCase): @patch("pkgmgr.cli.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='.'. + handle_branch('close') should call close_branch with name, base, + cwd='.' and force=False by default. """ args = SimpleNamespace( command="branch", subcommand="close", name="feature/cli-close", base="develop", + force=False, ) ctx = self._dummy_ctx() @@ -91,6 +97,7 @@ class TestCliBranch(unittest.TestCase): self.assertEqual(call_kwargs.get("name"), "feature/cli-close") self.assertEqual(call_kwargs.get("base_branch"), "develop") self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertFalse(call_kwargs.get("force")) @patch("pkgmgr.cli.commands.branch.close_branch") def test_handle_branch_close_uses_default_base_when_not_set(self, mock_close_branch) -> None: @@ -103,6 +110,7 @@ class TestCliBranch(unittest.TestCase): subcommand="close", name=None, base="main", + force=False, ) ctx = self._dummy_ctx() @@ -114,6 +122,113 @@ class TestCliBranch(unittest.TestCase): self.assertIsNone(call_kwargs.get("name")) self.assertEqual(call_kwargs.get("base_branch"), "main") self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertFalse(call_kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.close_branch") + def test_handle_branch_close_with_force_true(self, mock_close_branch) -> None: + """ + handle_branch('close') should pass force=True when the args specify it. + """ + args = SimpleNamespace( + command="branch", + subcommand="close", + name="feature/cli-close-force", + base="main", + force=True, + ) + + 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-force") + self.assertEqual(call_kwargs.get("base_branch"), "main") + self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertTrue(call_kwargs.get("force")) + + # ------------------------------------------------------------------ + # drop subcommand + # ------------------------------------------------------------------ + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_handle_branch_drop_forwards_args_to_drop_branch(self, mock_drop_branch) -> None: + """ + handle_branch('drop') should call drop_branch with name, base, + cwd='.' and force=False by default. + """ + args = SimpleNamespace( + command="branch", + subcommand="drop", + name="feature/cli-drop", + base="develop", + force=False, + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_drop_branch.assert_called_once() + _, call_kwargs = mock_drop_branch.call_args + self.assertEqual(call_kwargs.get("name"), "feature/cli-drop") + self.assertEqual(call_kwargs.get("base_branch"), "develop") + self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertFalse(call_kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_handle_branch_drop_uses_default_base_when_not_set(self, mock_drop_branch) -> None: + """ + If --base is not passed for 'drop', argparse gives base='main' + (default), and handle_branch should propagate that to drop_branch. + """ + args = SimpleNamespace( + command="branch", + subcommand="drop", + name=None, + base="main", + force=False, + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_drop_branch.assert_called_once() + _, call_kwargs = mock_drop_branch.call_args + self.assertIsNone(call_kwargs.get("name")) + self.assertEqual(call_kwargs.get("base_branch"), "main") + self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertFalse(call_kwargs.get("force")) + + @patch("pkgmgr.cli.commands.branch.drop_branch") + def test_handle_branch_drop_with_force_true(self, mock_drop_branch) -> None: + """ + handle_branch('drop') should pass force=True when the args specify it. + """ + args = SimpleNamespace( + command="branch", + subcommand="drop", + name="feature/cli-drop-force", + base="main", + force=True, + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_drop_branch.assert_called_once() + _, call_kwargs = mock_drop_branch.call_args + self.assertEqual(call_kwargs.get("name"), "feature/cli-drop-force") + self.assertEqual(call_kwargs.get("base_branch"), "main") + self.assertEqual(call_kwargs.get("cwd"), ".") + self.assertTrue(call_kwargs.get("force")) + + # ------------------------------------------------------------------ + # unknown subcommand + # ------------------------------------------------------------------ def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None: """