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:
80
pkgmgr/branch_commands.py
Normal file
80
pkgmgr/branch_commands.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
High-level helpers for branch-related operations.
|
||||
|
||||
This module encapsulates the actual Git logic so the CLI layer
|
||||
(pkgmgr.cli_core.commands.branch) stays thin and testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.git_utils import run_git, GitError
|
||||
|
||||
|
||||
def open_branch(
|
||||
name: Optional[str],
|
||||
base_branch: str = "main",
|
||||
cwd: str = ".",
|
||||
) -> None:
|
||||
"""
|
||||
Create and push a new feature branch on top of `base_branch`.
|
||||
|
||||
Steps:
|
||||
1) git fetch origin
|
||||
2) git checkout <base_branch>
|
||||
3) git pull origin <base_branch>
|
||||
4) git checkout -b <name>
|
||||
5) git push -u origin <name>
|
||||
|
||||
If `name` is None or empty, the user is prompted on stdin.
|
||||
"""
|
||||
|
||||
if not name:
|
||||
name = input("Enter new branch name: ").strip()
|
||||
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
# 1) Fetch from origin
|
||||
try:
|
||||
run_git(["fetch", "origin"], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 2) Checkout base branch
|
||||
try:
|
||||
run_git(["checkout", base_branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to checkout base branch {base_branch!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 3) Pull latest changes on base
|
||||
try:
|
||||
run_git(["pull", "origin", base_branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to pull latest changes for base branch {base_branch!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 4) Create new branch
|
||||
try:
|
||||
run_git(["checkout", "-b", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to create new branch {name!r} from base {base_branch!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 5) Push and set upstream
|
||||
try:
|
||||
run_git(["push", "-u", "origin", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to push new branch {name!r} to origin: {exc}"
|
||||
) from exc
|
||||
@@ -5,6 +5,7 @@ from .release import handle_release
|
||||
from .version import handle_version
|
||||
from .make import handle_make
|
||||
from .changelog import handle_changelog
|
||||
from .branch import handle_branch
|
||||
|
||||
__all__ = [
|
||||
"handle_repos_command",
|
||||
@@ -14,4 +15,5 @@ __all__ = [
|
||||
"handle_version",
|
||||
"handle_make",
|
||||
"handle_changelog",
|
||||
"handle_branch",
|
||||
]
|
||||
|
||||
25
pkgmgr/cli_core/commands/branch.py
Normal file
25
pkgmgr/cli_core/commands/branch.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.branch_commands import open_branch
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
"""
|
||||
Handle `pkgmgr branch` subcommands.
|
||||
|
||||
Currently supported:
|
||||
- pkgmgr branch open [<name>] [--base <branch>]
|
||||
"""
|
||||
if args.subcommand == "open":
|
||||
open_branch(
|
||||
name=getattr(args, "name", None),
|
||||
base_branch=getattr(args, "base", "main"),
|
||||
cwd=".",
|
||||
)
|
||||
return
|
||||
|
||||
print(f"Unknown branch subcommand: {args.subcommand}")
|
||||
sys.exit(2)
|
||||
@@ -15,6 +15,7 @@ from pkgmgr.cli_core.commands import (
|
||||
handle_config,
|
||||
handle_make,
|
||||
handle_changelog,
|
||||
handle_branch,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,6 +48,7 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
||||
"version",
|
||||
"make",
|
||||
"changelog",
|
||||
# intentionally NOT "branch" – it operates on cwd only
|
||||
]
|
||||
|
||||
if args.command in commands_with_selection:
|
||||
@@ -83,6 +85,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
||||
handle_config(args, ctx)
|
||||
elif args.command == "make":
|
||||
handle_make(args, ctx, selected)
|
||||
elif args.command == "branch":
|
||||
# Branch commands currently operate on the current working
|
||||
# directory only, not on the pkgmgr repository selection.
|
||||
handle_branch(args, ctx)
|
||||
else:
|
||||
print(f"Unknown command: {args.command}")
|
||||
sys.exit(2)
|
||||
|
||||
@@ -269,6 +269,35 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
default=[],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# branch
|
||||
# ------------------------------------------------------------
|
||||
branch_parser = subparsers.add_parser(
|
||||
"branch",
|
||||
help="Branch-related utilities (e.g. open feature branches)",
|
||||
)
|
||||
branch_subparsers = branch_parser.add_subparsers(
|
||||
dest="subcommand",
|
||||
help="Branch subcommands",
|
||||
required=True,
|
||||
)
|
||||
|
||||
branch_open = branch_subparsers.add_parser(
|
||||
"open",
|
||||
help="Create and push a new branch on top of a base branch",
|
||||
)
|
||||
branch_open.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
help="Name of the new branch (optional; will be asked interactively if omitted)",
|
||||
)
|
||||
branch_open.add_argument(
|
||||
"--base",
|
||||
default="main",
|
||||
help="Base branch to create the new branch from (default: main)",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# release
|
||||
# ------------------------------------------------------------
|
||||
@@ -306,8 +335,6 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
)
|
||||
add_identifier_arguments(version_parser)
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# changelog
|
||||
# ------------------------------------------------------------
|
||||
|
||||
63
tests/e2e/test_integration_branch_commands.py
Normal file
63
tests/e2e/test_integration_branch_commands.py
Normal 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()
|
||||
100
tests/unit/pkgmgr/test_branch_commands.py
Normal file
100
tests/unit/pkgmgr/test_branch_commands.py
Normal 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()
|
||||
86
tests/unit/pkgmgr/test_cli_branch.py
Normal file
86
tests/unit/pkgmgr/test_cli_branch.py
Normal 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()
|
||||
Reference in New Issue
Block a user