Add branch CLI command and tests (see ChatGPT conversation: https://chatgpt.com/share/69370ce1-8090-800f-8b08-8ecfa5089a74)

Signed-off-by: Kevin Veen-Birkenbach <kevin@veen.world>
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-08 18:37:59 +01:00
parent 22b65f83d3
commit b9b64fed7d
8 changed files with 391 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import runpy
import sys
import unittest
from unittest.mock import patch
class TestIntegrationBranchCommands(unittest.TestCase):
"""
E2E-style tests for the 'pkgmgr branch' CLI wiring.
We do NOT call real git; instead we patch pkgmgr.branch_commands.open_branch
and verify that the CLI invokes it with the correct parameters.
"""
def _run_pkgmgr(self, argv: list[str]) -> None:
"""
Helper to run 'pkgmgr' via its entry module with a given argv.
"""
original_argv = list(sys.argv)
try:
# argv typically looks like: ["pkgmgr", "branch", ...]
sys.argv = argv
# Run the CLI entry point
runpy.run_module("pkgmgr.cli", run_name="__main__")
finally:
sys.argv = original_argv
@patch("pkgmgr.branch_commands.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch) -> None:
"""
pkgmgr branch open feature/test --base develop
should invoke open_branch(name='feature/test', base_branch='develop', cwd='.')
"""
self._run_pkgmgr(
["pkgmgr", "branch", "open", "feature/test", "--base", "develop"]
)
mock_open_branch.assert_called_once()
_, kwargs = mock_open_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/test")
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.branch_commands.open_branch")
def test_branch_open_without_name_uses_default_base(self, mock_open_branch) -> None:
"""
pkgmgr branch open
should invoke open_branch(name=None, base_branch='main', cwd='.')
(the branch name will be asked interactively inside open_branch).
"""
self._run_pkgmgr(["pkgmgr", "branch", "open"])
mock_open_branch.assert_called_once()
_, kwargs = mock_open_branch.call_args
self.assertIsNone(kwargs.get("name"))
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.branch_commands import open_branch
from pkgmgr.git_utils import GitError
class TestOpenBranch(unittest.TestCase):
@patch("pkgmgr.branch_commands.run_git")
def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None:
"""
open_branch(name, base='main') should:
- fetch origin
- checkout base
- pull base
- create new branch
- push with upstream
"""
mock_run_git.return_value = ""
open_branch(name="feature/test", base_branch="main", cwd="/repo")
# We expect a specific sequence of Git calls.
expected_calls = [
(["fetch", "origin"], "/repo"),
(["checkout", "main"], "/repo"),
(["pull", "origin", "main"], "/repo"),
(["checkout", "-b", "feature/test"], "/repo"),
(["push", "-u", "origin", "feature/test"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("builtins.input", return_value="feature/interactive")
@patch("pkgmgr.branch_commands.run_git")
def test_open_branch_prompts_for_name_if_missing(
self,
mock_run_git,
mock_input,
) -> None:
"""
If name is None/empty, open_branch should prompt via input()
and still perform the full Git sequence.
"""
mock_run_git.return_value = ""
open_branch(name=None, base_branch="develop", cwd="/repo")
# Ensure we asked for input exactly once
mock_input.assert_called_once()
expected_calls = [
(["fetch", "origin"], "/repo"),
(["checkout", "develop"], "/repo"),
(["pull", "origin", "develop"], "/repo"),
(["checkout", "-b", "feature/interactive"], "/repo"),
(["push", "-u", "origin", "feature/interactive"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("pkgmgr.branch_commands.run_git")
def test_open_branch_raises_runtimeerror_on_git_failure(self, mock_run_git) -> None:
"""
If a GitError occurs (e.g. fetch fails), open_branch should
raise a RuntimeError with a helpful message.
"""
def side_effect(args, cwd="."):
# Simulate a failure on the first call (fetch)
raise GitError("simulated fetch failure")
mock_run_git.side_effect = side_effect
with self.assertRaises(RuntimeError) as cm:
open_branch(name="feature/fail", base_branch="main", cwd="/repo")
msg = str(cm.exception)
self.assertIn("Failed to fetch from origin", msg)
self.assertIn("simulated fetch failure", msg)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.cli_core.commands.branch import handle_branch
from pkgmgr.cli_core.context import CLIContext
class TestCliBranch(unittest.TestCase):
def _dummy_ctx(self) -> CLIContext:
"""
Minimal CLIContext; handle_branch does not actually use it,
but we keep the type consistent.
"""
return CLIContext(
config_merged={},
repositories_base_dir="/tmp/repos",
all_repositories=[],
binaries_dir="/tmp/bin",
user_config_path="/tmp/config.yaml",
)
@patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_handle_branch_open_forwards_args_to_open_branch(self, mock_open_branch) -> None:
"""
handle_branch('open') should call open_branch with name, base and cwd='.'.
"""
args = SimpleNamespace(
command="branch",
subcommand="open",
name="feature/cli-test",
base="develop",
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_open_branch.assert_called_once()
call_args, call_kwargs = mock_open_branch.call_args
self.assertEqual(call_kwargs.get("name"), "feature/cli-test")
self.assertEqual(call_kwargs.get("base_branch"), "develop")
self.assertEqual(call_kwargs.get("cwd"), ".")
@patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_handle_branch_open_uses_default_base_when_not_set(self, mock_open_branch) -> None:
"""
If --base is not passed, argparse gives base='main' (default),
and handle_branch should propagate that to open_branch.
"""
args = SimpleNamespace(
command="branch",
subcommand="open",
name=None,
base="main",
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_open_branch.assert_called_once()
_, call_kwargs = mock_open_branch.call_args
self.assertIsNone(call_kwargs.get("name"))
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None:
"""
Unknown branch subcommand should result in SystemExit(2).
"""
args = SimpleNamespace(
command="branch",
subcommand="unknown",
)
ctx = self._dummy_ctx()
with self.assertRaises(SystemExit) as cm:
handle_branch(args, ctx)
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()