Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd74ad41f9 | ||
|
|
fa2a92481d | ||
|
|
6a1e001fc2 | ||
|
|
60afa92e09 | ||
|
|
212f3ce5eb | ||
|
|
0d79537033 | ||
|
|
72fc69c2f8 | ||
|
|
6d8c6deae8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ Thumbs.db
|
||||
# Nix Cache to speed up tests
|
||||
.nix/
|
||||
.nix-dev-installed
|
||||
flake.lock
|
||||
|
||||
# Ignore logs
|
||||
*.log
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,3 +1,47 @@
|
||||
## [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 🎉**
|
||||
*First stable release of PKGMGR, the multi-distro development and package workflow manager.*
|
||||
|
||||
---
|
||||
|
||||
**Key Features**
|
||||
|
||||
**Core Functionality**
|
||||
|
||||
* Manage many repositories with one CLI: `clone`, `update`, `install`, `list`, `path`, `config`
|
||||
* Proxy wrappers for Git, Docker/Compose and Make
|
||||
* Multi-repo execution with safe *preview mode*
|
||||
* Mirror management: `mirror list/diff/merge/setup`
|
||||
|
||||
**Releases & Versioning**
|
||||
|
||||
* Automated SemVer bumps, tagging and changelog generation
|
||||
* Supports PKGBUILD, Debian, RPM, pyproject.toml, flake.nix
|
||||
|
||||
**Developer Tools**
|
||||
|
||||
* Open repositories in VS Code, file manager or terminal
|
||||
* Unified workflows across all major Linux distros
|
||||
|
||||
**Nix Integration**
|
||||
|
||||
* Cross-distro reproducible builds via Nix flakes
|
||||
* CI-tested across all supported environments
|
||||
|
||||
---
|
||||
|
||||
**Summary**
|
||||
PKGMGR 1.0.0 unifies repository management, build tooling, release automation and reproducible multi-distro workflows into one cohesive CLI tool.
|
||||
|
||||
*This is the first official stable release.*
|
||||
|
||||
|
||||
## [0.10.2] - 2025-12-11
|
||||
|
||||
* * Stable tag now updates only when a new highest version is released.
|
||||
|
||||
166
README.md
166
README.md
@@ -1,68 +1,186 @@
|
||||
# Package Manager 🤖📦
|
||||
|
||||

|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](https://s.veen.world/paypaldonate)
|
||||
[](LICENSE)
|
||||
[](https://github.com/kevinveenbirkenbach/package-manager)
|
||||
|
||||
*Kevins's* Package Manager is a configurable Python tool designed to manage multiple repositories via Bash. It automates common Git operations such as clone, pull, push, status, and more. Additionally, it handles the creation of executable wrappers and alias links for your repositories.
|
||||
**Kevin's Package Manager (PKGMGR)** is a *multi-distro* package manager and workflow orchestrator.
|
||||
It helps you **develop, package, release and manage projects across multiple Linux-based
|
||||
operating systems** (Arch, Debian, Ubuntu, Fedora, CentOS, …).
|
||||
|
||||
PKGMGR is implemented in **Python** and uses **Nix (flakes)** as a foundation for
|
||||
distribution-independent builds and tooling. On top of that it provides a rich
|
||||
CLI that proxies common developer tools (Git, Docker, Make, …) and glues them
|
||||
together into repeatable development workflows.
|
||||
|
||||
---
|
||||
|
||||
## Why PKGMGR? 🧠
|
||||
|
||||
Traditional distro package managers like `apt`, `pacman` or `dnf` focus on a
|
||||
single operating system. PKGMGR instead focuses on **your repositories and
|
||||
development lifecycle**:
|
||||
|
||||
* one configuration for all your repos,
|
||||
* one CLI to interact with them,
|
||||
* one Nix-based layer to keep tooling reproducible across distros.
|
||||
|
||||
You keep using your native package manager where it makes sense – PKGMGR
|
||||
coordinates the *development and release flow* around it.
|
||||
|
||||
---
|
||||
|
||||
## Features 🚀
|
||||
|
||||
- **Installation & Setup:**
|
||||
Create executable wrappers with auto-detected commands (e.g. `main.sh` or `main.py`).
|
||||
### Multi-distro development & packaging
|
||||
|
||||
- **Git Operations:**
|
||||
Easily perform `git pull`, `push`, `status`, `commit`, `diff`, `add`, `show`, and `checkout` with extra parameters passed through.
|
||||
* Manage **many repositories at once** from a single `config/config.yaml`.
|
||||
* Drive full **release pipelines** across Linux distributions using:
|
||||
|
||||
- **Configuration Management:**
|
||||
Manage repository configurations via a default file (`config/defaults.yaml`) and a user-specific file (`config/config.yaml`). Initialize, add, delete, or ignore entries using subcommands.
|
||||
* Nix flakes (`flake.nix`)
|
||||
* PyPI style builds (`pyproject.toml`)
|
||||
* OS packages (PKGBUILD, Debian control/changelog, RPM spec)
|
||||
* Ansible Galaxy metadata and more.
|
||||
|
||||
- **Path & Listing:**
|
||||
Display repository paths or list all configured packages with their details.
|
||||
### Rich CLI for daily work
|
||||
|
||||
- **Custom Aliases:**
|
||||
Generate and manage custom aliases for easy command invocation.
|
||||
All commands are exposed via the `pkgmgr` CLI and are available on every distro:
|
||||
|
||||
* **Repository management**
|
||||
|
||||
* `clone`, `update`, `install`, `delete`, `deinstall`, `path`, `list`, `config`
|
||||
* **Git proxies**
|
||||
|
||||
* `pull`, `push`, `status`, `diff`, `add`, `show`, `checkout`,
|
||||
`reset`, `revert`, `rebase`, `commit`, `branch`
|
||||
* **Docker & Compose orchestration**
|
||||
|
||||
* `build`, `up`, `down`, `exec`, `ps`, `start`, `stop`, `restart`
|
||||
* **Release toolchain**
|
||||
|
||||
* `version`, `release`, `changelog`, `make`
|
||||
* **Mirror & workflow helpers**
|
||||
|
||||
* `mirror` (list/diff/merge/setup), `shell`, `terminal`, `code`, `explore`
|
||||
|
||||
Many of these commands support `--preview` mode so you can inspect the
|
||||
underlying Git or Docker calls without executing them.
|
||||
|
||||
### Full development workflows
|
||||
|
||||
PKGMGR is not just a helper around Git commands. Combined with its release and
|
||||
versioning features it can drive **end-to-end workflows**:
|
||||
|
||||
1. Clone and mirror repositories.
|
||||
2. Run tests and builds through `make` or Nix.
|
||||
3. Bump versions, update changelogs and tags.
|
||||
4. Build distro-specific packages.
|
||||
5. Keep all mirrors and working copies in sync.
|
||||
|
||||
The extensive E2E tests (`tests/e2e/`) and GitHub Actions workflows (including
|
||||
“virgin user” and “virgin root” Arch tests) validate these flows across
|
||||
different Linux environments.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Setup Map 🗺️
|
||||
|
||||
The following diagram provides a full overview of PKGMGR’s package structure,
|
||||
installation layers, and setup controller flow:
|
||||
The following diagram gives a full overview of:
|
||||
|
||||
* PKGMGR’s package structure,
|
||||
* the layered installers (OS, foundation, Python, Makefile),
|
||||
* and the setup controller that decides which layer to use on a given system.
|
||||
|
||||

|
||||
|
||||
**Diagram status:** *Stand: 11. Dezember 2025*
|
||||
**Always-up-to-date version:** https://s.veen.world/pkgmgrmp
|
||||
**Diagram status:** 11 December 2025
|
||||
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||
|
||||
---
|
||||
|
||||
## Installation ⚙️
|
||||
|
||||
Clone the repository and ensure your `~/.local/bin` is in your system PATH:
|
||||
### 1. Get the latest stable version
|
||||
|
||||
For a stable setup, use the **latest tagged release** (the tag pointed to by
|
||||
`latest`):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kevinveenbirkenbach/package-manager.git
|
||||
cd package-manager
|
||||
|
||||
# Optional but recommended: checkout the latest stable tag
|
||||
git fetch --tags
|
||||
git checkout "$(git describe --tags --abbrev=0)"
|
||||
```
|
||||
|
||||
Install make and pip if not installed yet:
|
||||
### 2. Install via Make
|
||||
|
||||
The project ships with a Makefile that encapsulates the typical installation
|
||||
flow. On most systems you only need:
|
||||
|
||||
```bash
|
||||
pacman -S make python-pip
|
||||
# Ensure make, Python and pip are installed via your distro package manager
|
||||
# (e.g. pacman -S make python python-pip, apt install make python3-pip, ...)
|
||||
|
||||
make install
|
||||
```
|
||||
|
||||
Then, run the following command to set up the project:
|
||||
This will:
|
||||
|
||||
* create or reuse a Python virtual environment,
|
||||
* install PKGMGR (and its Python dependencies) into that environment,
|
||||
* expose the `pkgmgr` executable on your PATH (usually via `~/.local/bin`),
|
||||
* prepare Nix-based integration where available so PKGMGR can build and manage
|
||||
packages distribution-independently.
|
||||
|
||||
For development use, you can also run:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
|
||||
The `make setup` command will:
|
||||
- Make `main.py` executable.
|
||||
- Install required packages from `requirements.txt`.
|
||||
- Execute `python main.py install` to complete the installation.
|
||||
which prepares the environment and leaves you with a fully wired development
|
||||
workspace (including Nix, tests and scripts).
|
||||
|
||||
---
|
||||
|
||||
## Usage 🧰
|
||||
|
||||
After installation, the main entry point is:
|
||||
|
||||
```bash
|
||||
pkgmgr --help
|
||||
```
|
||||
|
||||
This prints a list of all available subcommands, for example:
|
||||
|
||||
* `pkgmgr list --all` – show all repositories in the config
|
||||
* `pkgmgr update --all --clone-mode https` – update every repository
|
||||
* `pkgmgr release patch --preview` – simulate a patch release
|
||||
* `pkgmgr version --all` – show version information for all repositories
|
||||
* `pkgmgr mirror setup --preview --all` – prepare Git mirrors (no changes in preview)
|
||||
* `pkgmgr make install --preview pkgmgr` – preview make install for the pkgmgr repo
|
||||
|
||||
The help for each command is available via:
|
||||
|
||||
```bash
|
||||
pkgmgr <command> --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License 📄
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## Author 👤
|
||||
|
||||
|
||||
@@ -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 |
BIN
assets/map.png
BIN
assets/map.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
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 = "0.10.2";
|
||||
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 = "0.10.2"
|
||||
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."
|
||||
)
|
||||
@@ -18,52 +18,17 @@ USER_CONFIG_PATH = os.path.expanduser("~/.config/pkgmgr/config.yaml")
|
||||
|
||||
DESCRIPTION_TEXT = """\
|
||||
\033[1;32mPackage Manager 🤖📦\033[0m
|
||||
\033[3mKevin's Package Manager is a multi-repository, multi-package, and multi-format
|
||||
development tool crafted by and designed for:\033[0m
|
||||
\033[1;34mKevin Veen-Birkenbach\033[0m
|
||||
\033[4mhttps://www.veen.world/\033[0m
|
||||
\033[3mKevin's multi-distro package and workflow manager.\033[0m
|
||||
\033[1;34mKevin Veen-Birkenbach\033[0m – \033[4mhttps://www.veen.world/\033[0m
|
||||
|
||||
\033[1mOverview:\033[0m
|
||||
A powerful toolchain that unifies and automates workflows across heterogeneous
|
||||
project ecosystems. pkgmgr is not only a package manager — it is a full
|
||||
developer-oriented orchestration tool.
|
||||
Built in \033[1;33mPython\033[0m on top of \033[1;33mNix flakes\033[0m to manage many
|
||||
repositories and packaging formats (pyproject.toml, flake.nix,
|
||||
PKGBUILD, debian, Ansible, …) with one CLI.
|
||||
|
||||
It automatically detects, merges, and processes metadata from multiple
|
||||
dependency formats, including:
|
||||
• \033[1;33mPython:\033[0m pyproject.toml, requirements.txt
|
||||
• \033[1;33mNix:\033[0m flake.nix
|
||||
• \033[1;33mArch Linux:\033[0m PKGBUILD
|
||||
• \033[1;33mAnsible:\033[0m requirements.yml
|
||||
|
||||
This allows pkgmgr to perform installation, updates, verification, dependency
|
||||
resolution, and synchronization across complex multi-repo environments — with a
|
||||
single unified command-line interface.
|
||||
|
||||
\033[1mDeveloper Tools:\033[0m
|
||||
pkgmgr includes an integrated toolbox to enhance daily development workflows:
|
||||
|
||||
• \033[1;33mVS Code integration:\033[0m Auto-generate and open multi-repo workspaces
|
||||
• \033[1;33mTerminal integration:\033[0m Open repositories in new GNOME Terminal tabs
|
||||
• \033[1;33mExplorer integration:\033[0m Open repositories in your file manager
|
||||
• \033[1;33mRelease automation:\033[0m Version bumping, changelog updates, and tagging
|
||||
• \033[1;33mBatch operations:\033[0m Execute shell commands across multiple repositories
|
||||
• \033[1;33mGit/Docker/Make wrappers:\033[0m Unified command proxying for many tools
|
||||
|
||||
\033[1mCapabilities:\033[0m
|
||||
• Clone, pull, verify, update, and manage many repositories at once
|
||||
• Resolve dependencies across languages and ecosystems
|
||||
• Standardize install/update workflows
|
||||
• Create symbolic executable wrappers for any project
|
||||
• Merge configuration from default + user config layers
|
||||
|
||||
Use pkgmgr as both a robust package management framework and a versatile
|
||||
development orchestration tool.
|
||||
|
||||
For detailed help on each command, use:
|
||||
For details on any command, run:
|
||||
\033[1mpkgmgr <command> --help\033[0m
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Entry point for the pkgmgr CLI.
|
||||
|
||||
@@ -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