Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd74ad41f9 | ||
|
|
fa2a92481d | ||
|
|
6a1e001fc2 | ||
|
|
60afa92e09 | ||
|
|
212f3ce5eb | ||
|
|
0d79537033 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,8 +27,9 @@ Thumbs.db
|
||||
# Nix Cache to speed up tests
|
||||
.nix/
|
||||
.nix-dev-installed
|
||||
flake.lock
|
||||
|
||||
# Ignore logs
|
||||
*.log
|
||||
|
||||
result
|
||||
result
|
||||
|
||||
@@ -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 🎉**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Package Manager 🤖📦
|
||||
|
||||

|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
|
||||
@@ -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
BIN
assets/banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
27
flake.lock
generated
27
flake.lock
generated
@@ -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
|
||||
}
|
||||
@@ -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 = ./.;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
100
src/pkgmgr/actions/branch/close_branch.py
Normal file
100
src/pkgmgr/actions/branch/close_branch.py
Normal 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
|
||||
56
src/pkgmgr/actions/branch/drop_branch.py
Normal file
56
src/pkgmgr/actions/branch/drop_branch.py
Normal 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
|
||||
65
src/pkgmgr/actions/branch/open_branch.py
Normal file
65
src/pkgmgr/actions/branch/open_branch.py
Normal 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
|
||||
27
src/pkgmgr/actions/branch/utils.py
Normal file
27
src/pkgmgr/actions/branch/utils.py
Normal 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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
80
tests/e2e/test_branch_help.py
Normal file
80
tests/e2e/test_branch_help.py
Normal 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()
|
||||
248
tests/integration/test_branch_cli.py
Normal file
248
tests/integration/test_branch_cli.py
Normal 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()
|
||||
33
tests/unit/pkgmgr/actions/branch/__init__.py
Normal file
33
tests/unit/pkgmgr/actions/branch/__init__.py
Normal 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()
|
||||
55
tests/unit/pkgmgr/actions/branch/test_close_branch.py
Normal file
55
tests/unit/pkgmgr/actions/branch/test_close_branch.py
Normal 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()
|
||||
50
tests/unit/pkgmgr/actions/branch/test_drop_branch.py
Normal file
50
tests/unit/pkgmgr/actions/branch/test_drop_branch.py
Normal 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()
|
||||
37
tests/unit/pkgmgr/actions/branch/test_open_branch.py
Normal file
37
tests/unit/pkgmgr/actions/branch/test_open_branch.py
Normal 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()
|
||||
0
tests/unit/pkgmgr/actions/branch/test_utils.py
Normal file
0
tests/unit/pkgmgr/actions/branch/test_utils.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
"""
|
||||
Reference in New Issue
Block a user