Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
bd74ad41f9 Release version 1.1.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 09:08:22 +01:00
Kevin Veen-Birkenbach
fa2a92481d Merge branch 'main' of github.com:kevinveenbirkenbach/package-manager 2025-12-12 09:08:19 +01:00
Kevin Veen-Birkenbach
6a1e001fc2 test(branch): remove obsolete test_branch.py after branch module refactor
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
The old test tests/unit/pkgmgr/actions/test_branch.py has been removed because:

- it targeted the previous monolithic pkgmgr.actions.branch module structure
- its patch targets no longer match the refactored code
- its responsibilities are now fully covered by the new, dedicated unit,
  integration, and E2E tests for branch actions and CLI wiring

This avoids redundant coverage and prevents misleading or broken tests
after the branch refactor.

https://chatgpt.com/share/693bcc8d-b84c-800f-8510-8d6c66faf627
2025-12-12 09:04:11 +01:00
Kevin Veen-Birkenbach
60afa92e09 Removed flake.lock 2025-12-12 00:30:17 +01:00
Kevin Veen-Birkenbach
212f3ce5eb Removed _requirements.txt 2025-12-12 00:27:46 +01:00
Kevin Veen-Birkenbach
0d79537033 Added Banner
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 21:01:27 +01:00
25 changed files with 943 additions and 526 deletions

3
.gitignore vendored
View File

@@ -27,8 +27,9 @@ Thumbs.db
# Nix Cache to speed up tests
.nix/
.nix-dev-installed
flake.lock
# Ignore logs
*.log
result
result

View File

@@ -1,3 +1,8 @@
## [1.1.0] - 2025-12-12
* Added *branch drop* for destructive branch deletion and introduced *--force/-f* flags for branch close and branch drop to skip confirmation prompts.
## [1.0.0] - 2025-12-11
* **1.0.0 Official Stable Release 🎉**

View File

@@ -1,5 +1,7 @@
# Package Manager 🤖📦
![PKGMGR Banner](assets/banner.jpg)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)

View File

@@ -1,4 +0,0 @@
# Legacy file used only if pip still installs from requirements.txt.
# You may delete this file once you switch entirely to pyproject.toml.
PyYAML

BIN
assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

27
flake.lock generated
View File

@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

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

View File

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

View File

@@ -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 <resolved_base>
3) git pull origin <resolved_base>
4) git checkout -b <name>
5) git push -u origin <name>
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 <base>
6) git pull origin <base>
7) git merge --no-ff <name>
8) git push origin <base>
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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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."
)

View File

@@ -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 [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--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

View File

@@ -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",
)

View File

@@ -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 <argv_tail> --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()

View File

@@ -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 <name> --base <branch>` 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 <name>` 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 <name> --base <branch>` 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 <name> --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 <name> --base <branch>` 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 <name> --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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 <name> --base <branch>` 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 <name> --base <branch>` 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()

View File

@@ -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:
"""