Compare commits

...

13 Commits

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

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

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

https://chatgpt.com/share/693bcc8d-b84c-800f-8510-8d6c66faf627
2025-12-12 09:04:11 +01:00
Kevin Veen-Birkenbach
60afa92e09 Removed flake.lock 2025-12-12 00:30:17 +01:00
Kevin Veen-Birkenbach
212f3ce5eb Removed _requirements.txt 2025-12-12 00:27:46 +01:00
Kevin Veen-Birkenbach
0d79537033 Added Banner
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 21:01:27 +01:00
Kevin Veen-Birkenbach
72fc69c2f8 Release version 1.0.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 20:41:35 +01:00
Kevin Veen-Birkenbach
6d8c6deae8 **refactor(readme): rewrite README for multi-distro focus and Nix-based workflows**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Expanded and modernized the README to reflect PKGMGR's purpose as a
multi-distro development and packaging orchestrator. Added explanations for
Nix-based cross-distro workflows, clarified installation steps, documented the
full CLI capabilities, and embedded the architecture diagram.

Also replaced the verbose CLI DESCRIPTION_TEXT with a concise summary suitable
for `--help` output.

Included updated `assets/map.png`.

https://chatgpt.com/share/693b1d71-ca08-800f-a000-f3be49f7efb5
2025-12-11 20:37:05 +01:00
Kevin Veen-Birkenbach
6c116a029e Release version 0.10.2
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 20:16:59 +01:00
Kevin Veen-Birkenbach
3eb7c81fa1 **Mark stable only on highest version tag**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Updated the `mark-stable` workflow so that the `stable` tag is only moved when:

* the current push is a version tag (`v*`)
* all tests have passed
* the pushed version tag is the highest semantic version among all existing tags

This ensures that `stable` always reflects the latest valid release and prevents older version tags from overwriting it.

https://chatgpt.com/share/693b163b-0c34-800f-adcb-12cf4744dbe2
2025-12-11 20:06:22 +01:00
Kevin Veen-Birkenbach
0334f477fd Release version 0.10.2
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 20:01:29 +01:00
Kevin Veen-Birkenbach
8bb99c99b7 refactor(init-nix): unify installer logic and add robust retry handling
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Refactored the Nix initialization script to reduce duplicated code and
centralize the installation workflow. The core functionality remains
unchanged, but all installer calls now use a unified function with retry
support to ensure resilient downloads in CI and container environments.

Key improvements:
- Added download retry logic (5 minutes total, 20-second intervals)
- Consolidated installer invocation into `install_nix_with_retry`
- Reduced code duplication across container/host install paths
- Preserved existing installation behavior for all environments
- Maintained `nixbld` group and build-user handling
- Improved consistency and readability without altering semantics

This prevents intermittent failures such as:
“curl: (6) Could not resolve host: nixos.org”
and ensures stable, deterministic Nix setup in CI pipelines.

https://chatgpt.com/share/693b13ce-fdcc-800f-a7bc-81c67478edff
2025-12-11 19:56:10 +01:00
Kevin Veen-Birkenbach
587cb2e516 Removed comments
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-11 19:44:36 +01:00
30 changed files with 1334 additions and 766 deletions

View File

@@ -3,7 +3,9 @@ name: Mark stable commit
on:
push:
branches:
- main
- main # still run tests for main
tags:
- 'v*' # run tests for version tags (e.g. v0.9.1)
jobs:
test-unit:
@@ -34,31 +36,63 @@ jobs:
- test-virgin-root
runs-on: ubuntu-latest
# Only run this job if the push is for a version tag (v*)
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write # to move the tag
contents: write # Required to move/update the tag
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true # We need all tags for version comparison
- name: Move 'stable' tag to this commit
- name: Move 'stable' tag only if this version is the highest
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "Tagging commit $GITHUB_SHA as stable…"
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
# delete local tag if exists
VERSION="${GITHUB_REF#refs/tags/}"
echo "Current version tag: ${VERSION}"
echo "Collecting all version tags..."
ALL_V_TAGS="$(git tag --list 'v*' || true)"
if [[ -z "${ALL_V_TAGS}" ]]; then
echo "No version tags found. Skipping stable update."
exit 0
fi
echo "All version tags:"
echo "${ALL_V_TAGS}"
# Determine highest version using natural version sorting
LATEST_TAG="$(printf '%s\n' ${ALL_V_TAGS} | sort -V | tail -n1)"
echo "Highest version tag: ${LATEST_TAG}"
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
echo "Current version ${VERSION} is NOT the highest version."
echo "Stable tag will NOT be updated."
exit 0
fi
echo "Current version ${VERSION} IS the highest version."
echo "Updating 'stable' tag..."
# Delete existing stable tag (local + remote)
git tag -d stable 2>/dev/null || true
# delete remote tag if exists
git push origin :refs/tags/stable || true
# create new tag on this commit
# Create new stable tag
git tag stable "$GITHUB_SHA"
git push origin stable
echo "✅ Stable tag updated."
echo "✅ Stable tag updated to ${VERSION}."

3
.gitignore vendored
View File

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

View File

@@ -1,3 +1,56 @@
## [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.
* Debian package now includes sudo to ensure privilege escalation works reliably.
* Nix setup is significantly more resilient with retries, correct permissions, and better environment handling.
* AUR builder setup uses retries so yay installs succeed even under network instability.
* Nix flake installation now fails only on mandatory parts; optional outputs no longer block installation.
## [0.10.1] - 2025-12-11
* Fixed Debian\Ubuntu to pass container e2e tests
@@ -5,8 +58,6 @@
## [0.10.0] - 2025-12-11
* **Changes since v0.9.1**
**Mirror System**
* Added SSH mirror support including multi-push and remote probing

180
README.md
View File

@@ -1,70 +1,188 @@
# Package Manager🤖📦
# Package Manager 🤖📦
![PKGMGR Banner](assets/banner.jpg)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![GitHub license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![GitHub repo size](https://img.shields.io/github/repo-size/kevinveenbirkenbach/package-manager)](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`).
- **Git Operations:**
Easily perform `git pull`, `push`, `status`, `commit`, `diff`, `add`, `show`, and `checkout` with extra parameters passed through.
- **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.
- **Path & Listing:**
Display repository paths or list all configured packages with their details.
- **Custom Aliases:**
Generate and manage custom aliases for easy command invocation.
### Multi-distro development & packaging
* Manage **many repositories at once** from a single `config/config.yaml`.
* Drive full **release pipelines** across Linux distributions using:
* Nix flakes (`flake.nix`)
* PyPI style builds (`pyproject.toml`)
* OS packages (PKGBUILD, Debian control/changelog, RPM spec)
* Ansible Galaxy metadata and more.
### Rich CLI for daily work
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 PKGMGRs package structure,
installation layers, and setup controller flow:
The following diagram gives a full overview of:
* PKGMGRs package structure,
* the layered installers (OS, foundation, Python, Makefile),
* and the setup controller that decides which layer to use on a given system.
![PKGMGR Architecture](assets/map.png)
**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 👤
Kevin Veen-Birkenbach
Kevin Veen-Birkenbach
[https://www.veen.world](https://www.veen.world)

View File

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

BIN
assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

27
flake.lock generated
View File

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

View File

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

View File

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

View File

@@ -3,21 +3,22 @@ set -euo pipefail
echo "[init-nix] Starting Nix initialization..."
NIX_INSTALL_URL="${NIX_INSTALL_URL:-https://nixos.org/nix/install}"
NIX_DOWNLOAD_MAX_TIME=300 # 5 minutes
NIX_DOWNLOAD_SLEEP_INTERVAL=20 # 20 seconds
# ---------------------------------------------------------------------------
# Helper: detect whether we are inside a container (Docker/Podman/etc.)
# Detect whether we are inside a container (Docker/Podman/etc.)
# ---------------------------------------------------------------------------
is_container() {
# Docker / Podman markers
if [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]]; then
return 0
fi
# cgroup hints
if grep -qiE 'docker|container|podman|lxc' /proc/1/cgroup 2>/dev/null; then
return 0
fi
# Environment variable used by some runtimes
if [[ -n "${container:-}" ]]; then
return 0
fi
@@ -26,219 +27,206 @@ is_container() {
}
# ---------------------------------------------------------------------------
# Helper: ensure Nix binaries are on PATH (multi-user or single-user)
# Ensure Nix binaries are on PATH (multi-user or single-user)
# ---------------------------------------------------------------------------
ensure_nix_on_path() {
# Multi-user profile (daemon install)
if [[ -x /nix/var/nix/profiles/default/bin/nix ]]; then
export PATH="/nix/var/nix/profiles/default/bin:${PATH}"
fi
# Single-user profile (current user)
if [[ -x "${HOME}/.nix-profile/bin/nix" ]]; then
export PATH="${HOME}/.nix-profile/bin:${PATH}"
fi
# Single-user profile for dedicated "nix" user (container case)
if [[ -x /home/nix/.nix-profile/bin/nix ]]; then
export PATH="/home/nix/.nix-profile/bin:${PATH}"
fi
}
# ---------------------------------------------------------------------------
# Helper: ensure Nix build group and users exist (build-users-group = nixbld)
# Ensure Nix build group and users exist (build-users-group = nixbld)
# ---------------------------------------------------------------------------
ensure_nix_build_group() {
# Ensure nixbld group (build-users-group for Nix)
if ! getent group nixbld >/dev/null 2>&1; then
echo "[init-nix] Creating group 'nixbld'..."
groupadd -r nixbld
fi
# Ensure Nix build users (nixbld1..nixbld10) as members of nixbld
for i in $(seq 1 10); do
if ! id "nixbld$i" >/dev/null 2>&1; then
echo "[init-nix] Creating build user nixbld$i..."
# -r: system account, -g: primary group, -G: supplementary (ensures membership is listed)
useradd -r -g nixbld -G nixbld -s /usr/sbin/nologin "nixbld$i"
fi
done
}
# ---------------------------------------------------------------------------
# Fast path: Nix already available
# Download and run Nix installer with retry
# Usage: install_nix_with_retry daemon|no-daemon [run_as_user]
# ---------------------------------------------------------------------------
if command -v nix >/dev/null 2>&1; then
echo "[init-nix] Nix already available on PATH: $(command -v nix)"
exit 0
fi
install_nix_with_retry() {
local mode="$1"
local run_as="${2:-}"
local installer elapsed=0 mode_flag
ensure_nix_on_path
case "${mode}" in
daemon) mode_flag="--daemon" ;;
no-daemon) mode_flag="--no-daemon" ;;
*)
echo "[init-nix] ERROR: Invalid mode '${mode}', expected 'daemon' or 'no-daemon'."
exit 1
;;
esac
if command -v nix >/dev/null 2>&1; then
echo "[init-nix] Nix found after adjusting PATH: $(command -v nix)"
exit 0
fi
installer="$(mktemp -t nix-installer.XXXXXX)"
echo "[init-nix] Nix not found, starting installation logic..."
echo "[init-nix] Downloading Nix installer from ${NIX_INSTALL_URL} with retry (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
IN_CONTAINER=0
if is_container; then
IN_CONTAINER=1
echo "[init-nix] Detected container environment."
else
echo "[init-nix] No container detected."
fi
# ---------------------------------------------------------------------------
# Container + root: install Nix as dedicated "nix" user (single-user)
# ---------------------------------------------------------------------------
if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
echo "[init-nix] Running as root inside a container using dedicated 'nix' user."
# Ensure build group/users for Nix
ensure_nix_build_group
# Ensure "nix" user (home at /home/nix)
if ! id nix >/dev/null 2>&1; then
echo "[init-nix] Creating user 'nix'..."
# Resolve a valid shell path across distros:
# - Debian/Ubuntu: /bin/bash
# - Arch: /usr/bin/bash (often symlinked)
# Fall back to /bin/sh on ultra-minimal systems.
BASH_SHELL="$(command -v bash || true)"
if [[ -z "${BASH_SHELL}" ]]; then
BASH_SHELL="/bin/sh"
while true; do
if curl -fL "${NIX_INSTALL_URL}" -o "${installer}"; then
echo "[init-nix] Successfully downloaded Nix installer to ${installer}"
break
fi
useradd -m -r -g nixbld -s "${BASH_SHELL}" nix
fi
# Ensure /nix exists and is writable by the "nix" user.
#
# In some base images (or previous runs), /nix may already exist and be
# owned by root. In that case the Nix single-user installer will abort with:
#
# "directory /nix exists, but is not writable by you"
#
# To keep container runs idempotent and robust, we always enforce
# ownership nix:nixbld here.
if [[ ! -d /nix ]]; then
echo "[init-nix] Creating /nix with owner nix:nixbld..."
mkdir -m 0755 /nix
chown nix:nixbld /nix
else
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
echo "[init-nix] /nix already exists with owner ${current_owner}:${current_group} fixing to nix:nixbld..."
chown -R nix:nixbld /nix
local curl_exit=$?
echo "[init-nix] WARNING: Failed to download Nix installer (curl exit code ${curl_exit})."
elapsed=$((elapsed + NIX_DOWNLOAD_SLEEP_INTERVAL))
if (( elapsed >= NIX_DOWNLOAD_MAX_TIME )); then
echo "[init-nix] ERROR: Giving up after ${elapsed}s trying to download Nix installer."
rm -f "${installer}"
exit 1
fi
echo "[init-nix] Retrying in ${NIX_DOWNLOAD_SLEEP_INTERVAL}s (elapsed: ${elapsed}s/${NIX_DOWNLOAD_MAX_TIME}s)..."
sleep "${NIX_DOWNLOAD_SLEEP_INTERVAL}"
done
if [[ -n "${run_as}" ]]; then
echo "[init-nix] Running installer as user '${run_as}' with mode '${mode}'..."
if command -v sudo >/dev/null 2>&1; then
sudo -u "${run_as}" bash -lc "sh '${installer}' ${mode_flag}"
else
echo "[init-nix] /nix already exists with correct owner nix:nixbld."
su - "${run_as}" -c "sh '${installer}' ${mode_flag}"
fi
if [[ ! -w /nix ]]; then
echo "[init-nix] WARNING: /nix is still not writable after chown; Nix installer may fail."
fi
fi
# Run Nix single-user installer as "nix"
echo "[init-nix] Installing Nix as user 'nix' (single-user, --no-daemon)..."
if command -v sudo >/dev/null 2>&1; then
sudo -u nix bash -lc 'sh <(curl -L https://nixos.org/nix/install) --no-daemon'
else
su - nix -c 'sh <(curl -L https://nixos.org/nix/install) --no-daemon'
echo "[init-nix] Running installer as current user with mode '${mode}'..."
sh "${installer}" "${mode_flag}"
fi
# After installation, expose nix to root via PATH and symlink
ensure_nix_on_path
rm -f "${installer}"
}
if [[ -x /home/nix/.nix-profile/bin/nix ]]; then
if [[ ! -e /usr/local/bin/nix ]]; then
echo "[init-nix] Creating /usr/local/bin/nix symlink -> /home/nix/.nix-profile/bin/nix"
ln -s /home/nix/.nix-profile/bin/nix /usr/local/bin/nix
fi
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# Fast path: Nix already available
if command -v nix >/dev/null 2>&1; then
echo "[init-nix] Nix already available on PATH: $(command -v nix)"
return 0
fi
ensure_nix_on_path
if command -v nix >/dev/null 2>&1; then
echo "[init-nix] Nix successfully installed (container mode) at: $(command -v nix)"
else
echo "[init-nix] WARNING: Nix installation finished in container, but 'nix' is still not on PATH."
echo "[init-nix] Nix found after adjusting PATH: $(command -v nix)"
return 0
fi
# Optionally add PATH hints to /etc/profile (best effort)
if [[ -w /etc/profile ]]; then
if ! grep -q 'Nix profiles' /etc/profile 2>/dev/null; then
cat <<'EOF' >> /etc/profile
echo "[init-nix] Nix not found, starting installation logic..."
# Nix profiles (added by package-manager init-nix.sh)
if [ -d /nix/var/nix/profiles/default/bin ]; then
PATH="/nix/var/nix/profiles/default/bin:$PATH"
fi
if [ -d "$HOME/.nix-profile/bin" ]; then
PATH="$HOME/.nix-profile/bin:$PATH"
fi
EOF
echo "[init-nix] Appended Nix PATH setup to /etc/profile (container mode)."
fi
local IN_CONTAINER=0
if is_container; then
IN_CONTAINER=1
echo "[init-nix] Detected container environment."
else
echo "[init-nix] No container detected."
fi
echo "[init-nix] Nix initialization complete (container root mode)."
exit 0
fi
# -------------------------------------------------------------------------
# Container + root: dedicated "nix" user, single-user install
# -------------------------------------------------------------------------
if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
echo "[init-nix] Container + root installing as 'nix' user (single-user)."
# ---------------------------------------------------------------------------
# Non-container or non-root container: normal installer paths
# ---------------------------------------------------------------------------
if [[ "${IN_CONTAINER}" -eq 0 ]]; then
# Real host
if command -v systemctl >/dev/null 2>&1; then
echo "[init-nix] Host with systemd using multi-user install (--daemon)."
if [[ "${EUID:-0}" -eq 0 ]]; then
# Prepare build-users-group for Nix daemon installs
ensure_nix_build_group
ensure_nix_build_group
if ! id nix >/dev/null 2>&1; then
echo "[init-nix] Creating user 'nix'..."
local BASH_SHELL
BASH_SHELL="$(command -v bash || true)"
[[ -z "${BASH_SHELL}" ]] && BASH_SHELL="/bin/sh"
useradd -m -r -g nixbld -s "${BASH_SHELL}" nix
fi
sh <(curl -L https://nixos.org/nix/install) --daemon
else
if [[ "${EUID:-0}" -eq 0 ]]; then
echo "[init-nix] WARNING: Running as root without systemd on host."
echo "[init-nix] Falling back to single-user install (--no-daemon), but this is not recommended."
# IMPORTANT: This is where Debian/Ubuntu inside your CI end up.
# We must ensure 'nixbld' exists before running the installer,
# otherwise modern Nix fails with: "the group 'nixbld' ... does not exist".
ensure_nix_build_group
sh <(curl -L https://nixos.org/nix/install) --no-daemon
if [[ ! -d /nix ]]; then
echo "[init-nix] Creating /nix with owner nix:nixbld..."
mkdir -m 0755 /nix
chown nix:nixbld /nix
else
echo "[init-nix] Non-root host without systemd using single-user install (--no-daemon)."
# Non-root cannot create nixbld group; rely on upstream defaults
sh <(curl -L https://nixos.org/nix/install) --no-daemon
local current_owner current_group
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
echo "[init-nix] Fixing /nix ownership from ${current_owner}:${current_group} to nix:nixbld..."
chown -R nix:nixbld /nix
fi
if [[ ! -w /nix ]]; then
echo "[init-nix] WARNING: /nix is not writable after chown; Nix installer may fail."
fi
fi
fi
else
install_nix_with_retry "no-daemon" "nix"
ensure_nix_on_path
if [[ -x /home/nix/.nix-profile/bin/nix && ! -e /usr/local/bin/nix ]]; then
echo "[init-nix] Creating /usr/local/bin/nix symlink -> /home/nix/.nix-profile/bin/nix"
ln -s /home/nix/.nix-profile/bin/nix /usr/local/bin/nix
fi
# -------------------------------------------------------------------------
# Host (no container)
# -------------------------------------------------------------------------
elif [[ "${IN_CONTAINER}" -eq 0 ]]; then
if command -v systemctl >/dev/null 2>&1; then
echo "[init-nix] Host with systemd using multi-user install (--daemon)."
if [[ "${EUID:-0}" -eq 0 ]]; then
ensure_nix_build_group
fi
install_nix_with_retry "daemon"
else
if [[ "${EUID:-0}" -eq 0 ]]; then
echo "[init-nix] Host without systemd as root using single-user install (--no-daemon)."
ensure_nix_build_group
else
echo "[init-nix] Host without systemd as non-root using single-user install (--no-daemon)."
fi
install_nix_with_retry "no-daemon"
fi
# -------------------------------------------------------------------------
# Container, but not root (rare)
echo "[init-nix] Container as non-root user using single-user install (--no-daemon)."
sh <(curl -L https://nixos.org/nix/install) --no-daemon
fi
# -------------------------------------------------------------------------
else
echo "[init-nix] Container as non-root using single-user install (--no-daemon)."
install_nix_with_retry "no-daemon"
fi
# ---------------------------------------------------------------------------
# After installation: fix PATH (runtime + shell profiles)
# ---------------------------------------------------------------------------
ensure_nix_on_path
# -------------------------------------------------------------------------
# After installation: PATH + /etc/profile
# -------------------------------------------------------------------------
ensure_nix_on_path
if ! command -v nix >/dev/null 2>&1; then
echo "[init-nix] WARNING: Nix installation finished, but 'nix' is still not on PATH."
echo "[init-nix] You may need to source your shell profile manually."
exit 0
fi
if ! command -v nix >/dev/null 2>&1; then
echo "[init-nix] WARNING: Nix installation finished, but 'nix' is still not on PATH."
echo "[init-nix] You may need to source your shell profile manually."
else
echo "[init-nix] Nix successfully installed at: $(command -v nix)"
fi
echo "[init-nix] Nix successfully installed at: $(command -v nix)"
# Update global /etc/profile if writable (helps especially on minimal systems)
if [[ -w /etc/profile ]]; then
if ! grep -q 'Nix profiles' /etc/profile 2>/dev/null; then
if [[ -w /etc/profile ]] && ! grep -q 'Nix profiles' /etc/profile 2>/dev/null; then
cat <<'EOF' >> /etc/profile
# Nix profiles (added by package-manager init-nix.sh)
@@ -251,6 +239,8 @@ fi
EOF
echo "[init-nix] Appended Nix PATH setup to /etc/profile"
fi
fi
echo "[init-nix] Nix initialization complete."
echo "[init-nix] Nix initialization complete."
}
main "$@"

View File

@@ -49,8 +49,8 @@ echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
if ! "${RUN_AS_AUR[@]}" 'command -v yay >/dev/null 2>&1'; then
echo "[aur-builder-setup] yay not found starting retry sequence for download..."
MAX_TIME=300 # 5 minutes
SLEEP_INTERVAL=20 # 20 seconds
MAX_TIME=300
SLEEP_INTERVAL=20
ELAPSED=0
while true; do

View File

@@ -1,235 +1,14 @@
# pkgmgr/actions/branch/__init__.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
High-level helpers for branch-related operations.
This module encapsulates the actual Git logic so the CLI layer
(pkgmgr.cli.commands.branch) stays thin and testable.
Public API for branch actions.
"""
from __future__ import annotations
from .open_branch import open_branch
from .close_branch import close_branch
from .drop_branch import drop_branch
from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
# ---------------------------------------------------------------------------
# Branch creation (open)
# ---------------------------------------------------------------------------
def open_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
) -> None:
"""
Create and push a new feature branch on top of a base branch.
The base branch is resolved by:
1. Trying 'base_branch' (default: 'main')
2. Falling back to 'fallback_base' (default: 'master')
Steps:
1) git fetch origin
2) git checkout <resolved_base>
3) git pull origin <resolved_base>
4) git checkout -b <name>
5) git push -u origin <name>
If `name` is None or empty, the user is prompted to enter one.
"""
# Request name interactively if not provided
if not name:
name = input("Enter new branch name: ").strip()
if not name:
raise RuntimeError("Branch name must not be empty.")
# Resolve which base branch to use (main or master)
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
# 1) Fetch from origin
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
) from exc
# 2) Checkout base branch
try:
run_git(["checkout", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {resolved_base!r}: {exc}"
) from exc
# 3) Pull latest changes for base branch
try:
run_git(["pull", "origin", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
) from exc
# 4) Create new branch
try:
run_git(["checkout", "-b", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
) from exc
# 5) Push new branch to origin
try:
run_git(["push", "-u", "origin", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push new branch {name!r} to origin: {exc}"
) from exc
# ---------------------------------------------------------------------------
# Base branch resolver (shared by open/close)
# ---------------------------------------------------------------------------
def _resolve_base_branch(
preferred: str,
fallback: str,
cwd: str,
) -> str:
"""
Resolve the base branch to use.
Try `preferred` first (default: main),
fall back to `fallback` (default: master).
Raise RuntimeError if neither exists.
"""
for candidate in (preferred, fallback):
try:
run_git(["rev-parse", "--verify", candidate], cwd=cwd)
return candidate
except GitError:
continue
raise RuntimeError(
f"Neither {preferred!r} nor {fallback!r} exist in this repository."
)
# ---------------------------------------------------------------------------
# Branch closing (merge + deletion)
# ---------------------------------------------------------------------------
def close_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
) -> None:
"""
Merge a feature branch into the base branch and delete it afterwards.
Steps:
1) Determine the branch name (argument or current branch)
2) Resolve base branch (main/master)
3) Ask for confirmation
4) git fetch origin
5) git checkout <base>
6) git pull origin <base>
7) git merge --no-ff <name>
8) git push origin <base>
9) Delete branch locally
10) Delete branch on origin (best effort)
"""
# 1) Determine which branch should be closed
if not name:
try:
name = get_current_branch(cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
if not name:
raise RuntimeError("Branch name must not be empty.")
# 2) Resolve base branch
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
f"Refusing to close base branch {target_base!r}. "
"Please specify a feature branch."
)
# 3) Ask user for confirmation
prompt = (
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? "
"(y/N): "
)
answer = input(prompt).strip().lower()
if answer != "y":
print("Aborted closing branch.")
return
# 4) Fetch from origin
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc
# 5) Checkout base
try:
run_git(["checkout", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc
# 6) Pull latest base state
try:
run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc
# 7) Merge the feature branch
try:
run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to merge branch {name!r} into {target_base!r}: {exc}"
) from exc
# 8) Push updated base
try:
run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push base branch {target_base!r} after merge: {exc}"
) from exc
# 9) Delete branch locally
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to delete local branch {name!r}: {exc}"
) from exc
# 10) Delete branch on origin (best effort)
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc
__all__ = [
"open_branch",
"close_branch",
"drop_branch",
]

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
from .utils import _resolve_base_branch
def close_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
force: bool = False,
) -> None:
"""
Merge a feature branch into the base branch and delete it afterwards.
"""
# Determine branch name
if not name:
try:
name = get_current_branch(cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
if not name:
raise RuntimeError("Branch name must not be empty.")
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
f"Refusing to close base branch {target_base!r}. "
"Please specify a feature branch."
)
# Confirmation
if not force:
answer = input(
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): "
).strip().lower()
if answer != "y":
print("Aborted closing branch.")
return
# Fetch
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc
# Checkout base
try:
run_git(["checkout", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc
# Pull latest
try:
run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc
# Merge
try:
run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to merge branch {name!r} into {target_base!r}: {exc}"
) from exc
# Push result
try:
run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push base branch {target_base!r} after merge: {exc}"
) from exc
# Delete local
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to delete local branch {name!r}: {exc}"
) from exc
# Delete remote
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Branch {name!r} deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
from .utils import _resolve_base_branch
def drop_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
force: bool = False,
) -> None:
"""
Delete a branch locally and remotely without merging.
"""
if not name:
try:
name = get_current_branch(cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
if not name:
raise RuntimeError("Branch name must not be empty.")
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
f"Refusing to drop base branch {target_base!r}. It cannot be deleted."
)
# Confirmation
if not force:
answer = input(
f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): "
).strip().lower()
if answer != "y":
print("Aborted dropping branch.")
return
# Local delete
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to delete local branch {name!r}: {exc}") from exc
# Remote delete
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError
from .utils import _resolve_base_branch
def open_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
) -> None:
"""
Create and push a new feature branch on top of a base branch.
"""
# Request name interactively if not provided
if not name:
name = input("Enter new branch name: ").strip()
if not name:
raise RuntimeError("Branch name must not be empty.")
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
# 1) Fetch from origin
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
) from exc
# 2) Checkout base branch
try:
run_git(["checkout", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {resolved_base!r}: {exc}"
) from exc
# 3) Pull latest changes
try:
run_git(["pull", "origin", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
) from exc
# 4) Create new branch
try:
run_git(["checkout", "-b", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
) from exc
# 5) Push new branch
try:
run_git(["push", "-u", "origin", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push new branch {name!r} to origin: {exc}"
) from exc

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from pkgmgr.core.git import run_git, GitError
def _resolve_base_branch(
preferred: str,
fallback: str,
cwd: str,
) -> str:
"""
Resolve the base branch to use.
Try `preferred` first (default: main),
fall back to `fallback` (default: master).
Raise RuntimeError if neither exists.
"""
for candidate in (preferred, fallback):
try:
run_git(["rev-parse", "--verify", candidate], cwd=cwd)
return candidate
except GitError:
continue
raise RuntimeError(
f"Neither {preferred!r} nor {fallback!r} exist in this repository."
)

View File

@@ -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:
\033[1mpkgmgr <command> --help\033[0m
For details on any command, run:
\033[1mpkgmgr <command> --help\033[0m
"""
def main() -> None:
"""
Entry point for the pkgmgr CLI.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.branch import open_branch, close_branch
from pkgmgr.actions.branch import open_branch, close_branch, drop_branch
def handle_branch(args, ctx: CLIContext) -> None:
@@ -12,7 +12,8 @@ def handle_branch(args, ctx: CLIContext) -> None:
Currently supported:
- pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
"""
if args.subcommand == "open":
open_branch(
@@ -27,6 +28,16 @@ def handle_branch(args, ctx: CLIContext) -> None:
name=getattr(args, "name", None),
base_branch=getattr(args, "base", "main"),
cwd=".",
force=getattr(args, "force", False),
)
return
if args.subcommand == "drop":
drop_branch(
name=getattr(args, "name", None),
base_branch=getattr(args, "base", "main"),
cwd=".",
force=getattr(args, "force", False),
)
return

View File

@@ -14,7 +14,7 @@ def add_branch_subparsers(
"""
branch_parser = subparsers.add_parser(
"branch",
help="Branch-related utilities (e.g. open/close feature branches)",
help="Branch-related utilities (e.g. open/close/drop feature branches)",
)
branch_subparsers = branch_parser.add_subparsers(
dest="subcommand",
@@ -22,6 +22,9 @@ def add_branch_subparsers(
required=True,
)
# -----------------------------------------------------------------------
# branch open
# -----------------------------------------------------------------------
branch_open = branch_subparsers.add_parser(
"open",
help="Create and push a new branch on top of a base branch",
@@ -40,6 +43,9 @@ def add_branch_subparsers(
help="Base branch to create the new branch from (default: main)",
)
# -----------------------------------------------------------------------
# branch close
# -----------------------------------------------------------------------
branch_close = branch_subparsers.add_parser(
"close",
help="Merge a feature branch into base and delete it",
@@ -60,3 +66,39 @@ def add_branch_subparsers(
"internally if main does not exist)"
),
)
branch_close.add_argument(
"-f",
"--force",
action="store_true",
help="Skip confirmation prompt and close the branch directly",
)
# -----------------------------------------------------------------------
# branch drop
# -----------------------------------------------------------------------
branch_drop = branch_subparsers.add_parser(
"drop",
help="Delete a branch locally and on origin (without merging)",
)
branch_drop.add_argument(
"name",
nargs="?",
help=(
"Name of the branch to drop (optional; current branch is used "
"if omitted)"
),
)
branch_drop.add_argument(
"--base",
default="main",
help=(
"Base branch used to protect main/master from deletion "
"(default: main; falls back to master internally)"
),
)
branch_drop.add_argument(
"-f",
"--force",
action="store_true",
help="Skip confirmation prompt and drop the branch directly",
)

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import io
import runpy
import sys
import unittest
from contextlib import redirect_stdout, redirect_stderr
def _run_pkgmgr_help(argv_tail: list[str]) -> str:
"""
Run `pkgmgr <argv_tail> --help` via the main module and return captured output.
argparse parses sys.argv[1:], so argv[0] must be a dummy program name.
Any SystemExit with code 0 or None is treated as success.
"""
original_argv = list(sys.argv)
buffer = io.StringIO()
cmd_repr = "pkgmgr " + " ".join(argv_tail) + " --help"
try:
# IMPORTANT: argv[0] must be a dummy program name
sys.argv = ["pkgmgr"] + list(argv_tail) + ["--help"]
try:
with redirect_stdout(buffer), redirect_stderr(buffer):
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else None
if code not in (0, None):
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exc.code}."
) from exc
return buffer.getvalue()
finally:
sys.argv = original_argv
class TestBranchHelpE2E(unittest.TestCase):
"""
End-to-end tests ensuring that `pkgmgr branch` help commands
run without error and print usage information.
"""
def test_branch_root_help(self) -> None:
"""
`pkgmgr branch --help` should run without error.
"""
output = _run_pkgmgr_help(["branch"])
self.assertIn("usage:", output)
self.assertIn("pkgmgr branch", output)
def test_branch_open_help(self) -> None:
"""
`pkgmgr branch open --help` should run without error.
"""
output = _run_pkgmgr_help(["branch", "open"])
self.assertIn("usage:", output)
self.assertIn("branch open", output)
def test_branch_close_help(self) -> None:
"""
`pkgmgr branch close --help` should run without error.
"""
output = _run_pkgmgr_help(["branch", "close"])
self.assertIn("usage:", output)
self.assertIn("branch close", output)
def test_branch_drop_help(self) -> None:
"""
`pkgmgr branch drop --help` should run without error.
"""
output = _run_pkgmgr_help(["branch", "drop"])
self.assertIn("usage:", output)
self.assertIn("branch drop", output)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Integration tests for the `pkgmgr branch` CLI wiring.
These tests verify that:
- The argument parser creates the correct structure for
`branch open`, `branch close` and `branch drop`.
- `handle_branch` calls the corresponding helper functions
with the expected arguments (including base branch, cwd and force).
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.cli.parser import create_parser
from pkgmgr.cli.commands.branch import handle_branch
class TestBranchCLI(unittest.TestCase):
"""
Tests for the branch subcommands implemented in the CLI.
"""
def _create_parser(self):
"""
Create the top-level parser with a minimal description.
"""
return create_parser("pkgmgr test parser")
# --------------------------------------------------------------------- #
# branch open
# --------------------------------------------------------------------- #
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch):
"""
Ensure that `pkgmgr branch open <name> --base <branch>` calls
open_branch() with the correct parameters.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "open", "feature/test-branch", "--base", "develop"]
)
# Sanity check: parser wiring
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "open")
self.assertEqual(args.name, "feature/test-branch")
self.assertEqual(args.base, "develop")
# ctx is currently unused by handle_branch, so we can pass None
handle_branch(args, ctx=None)
mock_open_branch.assert_called_once()
_args, kwargs = mock_open_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/test-branch")
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_branch_open_with_name_and_default_base(self, mock_open_branch):
"""
Ensure that `pkgmgr branch open <name>` without --base uses
the default base branch 'main'.
"""
parser = self._create_parser()
args = parser.parse_args(["branch", "open", "feature/default-base"])
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "open")
self.assertEqual(args.name, "feature/default-base")
self.assertEqual(args.base, "main")
handle_branch(args, ctx=None)
mock_open_branch.assert_called_once()
_args, kwargs = mock_open_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/default-base")
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
# --------------------------------------------------------------------- #
# branch close
# --------------------------------------------------------------------- #
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_with_name_and_base(self, mock_close_branch):
"""
Ensure that `pkgmgr branch close <name> --base <branch>` calls
close_branch() with the correct parameters and force=False by default.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "close", "feature/old-branch", "--base", "main"]
)
# Sanity check: parser wiring
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "close")
self.assertEqual(args.name, "feature/old-branch")
self.assertEqual(args.base, "main")
self.assertFalse(args.force)
handle_branch(args, ctx=None)
mock_close_branch.assert_called_once()
_args, kwargs = mock_close_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/old-branch")
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertFalse(kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_without_name_uses_none(self, mock_close_branch):
"""
Ensure that `pkgmgr branch close` without a name passes name=None
into close_branch(), leaving branch resolution to the helper.
"""
parser = self._create_parser()
args = parser.parse_args(["branch", "close"])
# Parser wiring: no name → None
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "close")
self.assertIsNone(args.name)
self.assertEqual(args.base, "main")
self.assertFalse(args.force)
handle_branch(args, ctx=None)
mock_close_branch.assert_called_once()
_args, kwargs = mock_close_branch.call_args
self.assertIsNone(kwargs.get("name"))
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertFalse(kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_with_force(self, mock_close_branch):
"""
Ensure that `pkgmgr branch close <name> --force` passes force=True.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "close", "feature/old-branch", "--base", "main", "--force"]
)
self.assertTrue(args.force)
handle_branch(args, ctx=None)
mock_close_branch.assert_called_once()
_args, kwargs = mock_close_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/old-branch")
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertTrue(kwargs.get("force"))
# --------------------------------------------------------------------- #
# branch drop
# --------------------------------------------------------------------- #
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_branch_drop_with_name_and_base(self, mock_drop_branch):
"""
Ensure that `pkgmgr branch drop <name> --base <branch>` calls
drop_branch() with the correct parameters and force=False by default.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "drop", "feature/tmp-branch", "--base", "develop"]
)
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "drop")
self.assertEqual(args.name, "feature/tmp-branch")
self.assertEqual(args.base, "develop")
self.assertFalse(args.force)
handle_branch(args, ctx=None)
mock_drop_branch.assert_called_once()
_args, kwargs = mock_drop_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/tmp-branch")
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertFalse(kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_branch_drop_without_name(self, mock_drop_branch):
"""
Ensure that `pkgmgr branch drop` without a name passes name=None
into drop_branch(), leaving branch resolution to the helper.
"""
parser = self._create_parser()
args = parser.parse_args(["branch", "drop"])
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "drop")
self.assertIsNone(args.name)
self.assertEqual(args.base, "main")
self.assertFalse(args.force)
handle_branch(args, ctx=None)
mock_drop_branch.assert_called_once()
_args, kwargs = mock_drop_branch.call_args
self.assertIsNone(kwargs.get("name"))
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertFalse(kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_branch_drop_with_force(self, mock_drop_branch):
"""
Ensure that `pkgmgr branch drop <name> --force` passes force=True.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "drop", "feature/tmp-branch", "--force"]
)
self.assertTrue(args.force)
handle_branch(args, ctx=None)
mock_drop_branch.assert_called_once()
_args, kwargs = mock_drop_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/tmp-branch")
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
self.assertTrue(kwargs.get("force"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,33 @@
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.branch.utils import _resolve_base_branch
from pkgmgr.core.git import GitError
class TestResolveBaseBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.utils.run_git")
def test_resolves_preferred(self, run_git):
run_git.return_value = None
result = _resolve_base_branch("main", "master", cwd=".")
self.assertEqual(result, "main")
run_git.assert_called_with(["rev-parse", "--verify", "main"], cwd=".")
@patch("pkgmgr.actions.branch.utils.run_git")
def test_resolves_fallback(self, run_git):
run_git.side_effect = [
GitError("main missing"),
None,
]
result = _resolve_base_branch("main", "master", cwd=".")
self.assertEqual(result, "master")
@patch("pkgmgr.actions.branch.utils.run_git")
def test_raises_when_no_branch_exists(self, run_git):
run_git.side_effect = GitError("missing")
with self.assertRaises(RuntimeError):
_resolve_base_branch("main", "master", cwd=".")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,55 @@
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.branch.close_branch import close_branch
from pkgmgr.core.git import GitError
class TestCloseBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.close_branch.input", return_value="y")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_happy_path(self, run_git, resolve, current, input_mock):
close_branch(None, cwd=".")
expected = [
(["fetch", "origin"],),
(["checkout", "main"],),
(["pull", "origin", "main"],),
(["merge", "--no-ff", "feature-x"],),
(["push", "origin", "main"],),
(["branch", "-d", "feature-x"],),
(["push", "origin", "--delete", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected)
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
def test_refuses_to_close_base_branch(self, resolve, current):
with self.assertRaises(RuntimeError):
close_branch(None)
@patch("pkgmgr.actions.branch.close_branch.input", return_value="n")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
close_branch(None, cwd=".")
run_git.assert_not_called()
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_force_skips_prompt(self, run_git, resolve, current):
close_branch(None, cwd=".", force=True)
self.assertGreater(len(run_git.call_args_list), 0)
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitError("fail"))
def test_close_branch_errors_if_cannot_detect_branch(self, current):
with self.assertRaises(RuntimeError):
close_branch(None)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,50 @@
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.branch.drop_branch import drop_branch
from pkgmgr.core.git import GitError
class TestDropBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="y")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_happy_path(self, run_git, resolve, current, input_mock):
drop_branch(None, cwd=".")
expected = [
(["branch", "-d", "feature-x"],),
(["push", "origin", "--delete", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected)
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
def test_refuses_to_drop_base_branch(self, resolve, current):
with self.assertRaises(RuntimeError):
drop_branch(None)
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="n")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
drop_branch(None, cwd=".")
run_git.assert_not_called()
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_force_skips_prompt(self, run_git, resolve, current):
drop_branch(None, cwd=".", force=True)
self.assertGreater(len(run_git.call_args_list), 0)
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitError("fail"))
def test_drop_branch_errors_if_no_branch_detected(self, current):
with self.assertRaises(RuntimeError):
drop_branch(None)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.branch.open_branch import open_branch
class TestOpenBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.run_git")
def test_open_branch_executes_git_commands(self, run_git, resolve):
open_branch("feature-x", base_branch="main", cwd=".")
expected_calls = [
(["fetch", "origin"],),
(["checkout", "main"],),
(["pull", "origin", "main"],),
(["checkout", "-b", "feature-x"],),
(["push", "-u", "origin", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected_calls)
@patch("builtins.input", return_value="auto-branch")
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.run_git")
def test_open_branch_prompts_for_name(self, run_git, resolve, input_mock):
open_branch(None)
calls = [call.args for call in run_git.call_args_list]
self.assertEqual(calls[3][0][0], "checkout") # verify git executed normally
def test_open_branch_rejects_empty_name(self):
with patch("builtins.input", return_value=""):
with self.assertRaises(RuntimeError):
open_branch(None)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,146 +0,0 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.actions.branch import open_branch
from pkgmgr.core.git import GitError
class TestOpenBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None:
"""
open_branch(name, base='main') should:
- resolve base branch (prefers 'main', falls back to 'master')
- fetch origin
- checkout resolved base
- pull resolved base
- create new branch
- push with upstream
"""
mock_run_git.return_value = ""
open_branch(name="feature/test", base_branch="main", cwd="/repo")
# We expect a specific sequence of Git calls.
expected_calls = [
(["rev-parse", "--verify", "main"], "/repo"),
(["fetch", "origin"], "/repo"),
(["checkout", "main"], "/repo"),
(["pull", "origin", "main"], "/repo"),
(["checkout", "-b", "feature/test"], "/repo"),
(["push", "-u", "origin", "feature/test"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("builtins.input", return_value="feature/interactive")
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_prompts_for_name_if_missing(
self,
mock_run_git,
mock_input,
) -> None:
"""
If name is None/empty, open_branch should prompt via input()
and still perform the full Git sequence on the resolved base.
"""
mock_run_git.return_value = ""
open_branch(name=None, base_branch="develop", cwd="/repo")
# Ensure we asked for input exactly once
mock_input.assert_called_once()
expected_calls = [
(["rev-parse", "--verify", "develop"], "/repo"),
(["fetch", "origin"], "/repo"),
(["checkout", "develop"], "/repo"),
(["pull", "origin", "develop"], "/repo"),
(["checkout", "-b", "feature/interactive"], "/repo"),
(["push", "-u", "origin", "feature/interactive"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_raises_runtimeerror_on_fetch_failure(self, mock_run_git) -> None:
"""
If a GitError occurs on fetch, open_branch should raise a RuntimeError
with a helpful message.
"""
def side_effect(args, cwd="."):
# First call: base resolution (rev-parse) should succeed
if args == ["rev-parse", "--verify", "main"]:
return ""
# Second call: fetch should fail
if args == ["fetch", "origin"]:
raise GitError("simulated fetch failure")
return ""
mock_run_git.side_effect = side_effect
with self.assertRaises(RuntimeError) as cm:
open_branch(name="feature/fail", base_branch="main", cwd="/repo")
msg = str(cm.exception)
self.assertIn("Failed to fetch from origin", msg)
self.assertIn("simulated fetch failure", msg)
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_uses_fallback_master_if_main_missing(self, mock_run_git) -> None:
"""
If the preferred base (e.g. 'main') does not exist, open_branch should
fall back to the fallback base (default: 'master').
"""
def side_effect(args, cwd="."):
# First: rev-parse main -> fails
if args == ["rev-parse", "--verify", "main"]:
raise GitError("main does not exist")
# Second: rev-parse master -> succeeds
if args == ["rev-parse", "--verify", "master"]:
return ""
# Then normal flow on master
return ""
mock_run_git.side_effect = side_effect
open_branch(name="feature/fallback", base_branch="main", cwd="/repo")
expected_calls = [
(["rev-parse", "--verify", "main"], "/repo"),
(["rev-parse", "--verify", "master"], "/repo"),
(["fetch", "origin"], "/repo"),
(["checkout", "master"], "/repo"),
(["pull", "origin", "master"], "/repo"),
(["checkout", "-b", "feature/fallback"], "/repo"),
(["push", "-u", "origin", "feature/fallback"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,112 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Unit tests for the `pkgmgr branch` CLI wiring.
These tests verify that:
- The argument parser creates the correct structure for
`branch open` and `branch close`.
- `handle_branch` calls the corresponding helper functions
with the expected arguments (including base branch and cwd).
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.cli.parser import create_parser
from pkgmgr.cli.commands.branch import handle_branch
class TestBranchCLI(unittest.TestCase):
"""
Tests for the branch subcommands implemented in cli.
"""
def _create_parser(self):
"""
Create the top-level parser with a minimal description.
"""
return create_parser("pkgmgr test parser")
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch):
"""
Ensure that `pkgmgr branch open <name> --base <branch>` calls
open_branch() with the correct parameters.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "open", "feature/test-branch", "--base", "develop"]
)
# Sanity check: parser wiring
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "open")
self.assertEqual(args.name, "feature/test-branch")
self.assertEqual(args.base, "develop")
# ctx is currently unused by handle_branch, so we can pass None
handle_branch(args, ctx=None)
mock_open_branch.assert_called_once()
_args, kwargs = mock_open_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/test-branch")
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_with_name_and_base(self, mock_close_branch):
"""
Ensure that `pkgmgr branch close <name> --base <branch>` calls
close_branch() with the correct parameters.
"""
parser = self._create_parser()
args = parser.parse_args(
["branch", "close", "feature/old-branch", "--base", "main"]
)
# Sanity check: parser wiring
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "close")
self.assertEqual(args.name, "feature/old-branch")
self.assertEqual(args.base, "main")
handle_branch(args, ctx=None)
mock_close_branch.assert_called_once()
_args, kwargs = mock_close_branch.call_args
self.assertEqual(kwargs.get("name"), "feature/old-branch")
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_without_name_uses_none(self, mock_close_branch):
"""
Ensure that `pkgmgr branch close` without a name passes name=None
into close_branch(), leaving branch resolution to the helper.
"""
parser = self._create_parser()
args = parser.parse_args(["branch", "close"])
# Parser wiring: no name → None
self.assertEqual(args.command, "branch")
self.assertEqual(args.subcommand, "close")
self.assertIsNone(args.name)
handle_branch(args, ctx=None)
mock_close_branch.assert_called_once()
_args, kwargs = mock_close_branch.call_args
self.assertIsNone(kwargs.get("name"))
self.assertEqual(kwargs.get("base_branch"), "main")
self.assertEqual(kwargs.get("cwd"), ".")
if __name__ == "__main__":
unittest.main()

View File

@@ -22,6 +22,10 @@ class TestCliBranch(unittest.TestCase):
user_config_path="/tmp/config.yaml",
)
# ------------------------------------------------------------------
# open subcommand
# ------------------------------------------------------------------
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_handle_branch_open_forwards_args_to_open_branch(self, mock_open_branch) -> None:
"""
@@ -73,13 +77,15 @@ class TestCliBranch(unittest.TestCase):
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_handle_branch_close_forwards_args_to_close_branch(self, mock_close_branch) -> None:
"""
handle_branch('close') should call close_branch with name, base and cwd='.'.
handle_branch('close') should call close_branch with name, base,
cwd='.' and force=False by default.
"""
args = SimpleNamespace(
command="branch",
subcommand="close",
name="feature/cli-close",
base="develop",
force=False,
)
ctx = self._dummy_ctx()
@@ -91,6 +97,7 @@ class TestCliBranch(unittest.TestCase):
self.assertEqual(call_kwargs.get("name"), "feature/cli-close")
self.assertEqual(call_kwargs.get("base_branch"), "develop")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertFalse(call_kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_handle_branch_close_uses_default_base_when_not_set(self, mock_close_branch) -> None:
@@ -103,6 +110,7 @@ class TestCliBranch(unittest.TestCase):
subcommand="close",
name=None,
base="main",
force=False,
)
ctx = self._dummy_ctx()
@@ -114,6 +122,113 @@ class TestCliBranch(unittest.TestCase):
self.assertIsNone(call_kwargs.get("name"))
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertFalse(call_kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_handle_branch_close_with_force_true(self, mock_close_branch) -> None:
"""
handle_branch('close') should pass force=True when the args specify it.
"""
args = SimpleNamespace(
command="branch",
subcommand="close",
name="feature/cli-close-force",
base="main",
force=True,
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_close_branch.assert_called_once()
_, call_kwargs = mock_close_branch.call_args
self.assertEqual(call_kwargs.get("name"), "feature/cli-close-force")
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertTrue(call_kwargs.get("force"))
# ------------------------------------------------------------------
# drop subcommand
# ------------------------------------------------------------------
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_handle_branch_drop_forwards_args_to_drop_branch(self, mock_drop_branch) -> None:
"""
handle_branch('drop') should call drop_branch with name, base,
cwd='.' and force=False by default.
"""
args = SimpleNamespace(
command="branch",
subcommand="drop",
name="feature/cli-drop",
base="develop",
force=False,
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_drop_branch.assert_called_once()
_, call_kwargs = mock_drop_branch.call_args
self.assertEqual(call_kwargs.get("name"), "feature/cli-drop")
self.assertEqual(call_kwargs.get("base_branch"), "develop")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertFalse(call_kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_handle_branch_drop_uses_default_base_when_not_set(self, mock_drop_branch) -> None:
"""
If --base is not passed for 'drop', argparse gives base='main'
(default), and handle_branch should propagate that to drop_branch.
"""
args = SimpleNamespace(
command="branch",
subcommand="drop",
name=None,
base="main",
force=False,
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_drop_branch.assert_called_once()
_, call_kwargs = mock_drop_branch.call_args
self.assertIsNone(call_kwargs.get("name"))
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertFalse(call_kwargs.get("force"))
@patch("pkgmgr.cli.commands.branch.drop_branch")
def test_handle_branch_drop_with_force_true(self, mock_drop_branch) -> None:
"""
handle_branch('drop') should pass force=True when the args specify it.
"""
args = SimpleNamespace(
command="branch",
subcommand="drop",
name="feature/cli-drop-force",
base="main",
force=True,
)
ctx = self._dummy_ctx()
handle_branch(args, ctx)
mock_drop_branch.assert_called_once()
_, call_kwargs = mock_drop_branch.call_args
self.assertEqual(call_kwargs.get("name"), "feature/cli-drop-force")
self.assertEqual(call_kwargs.get("base_branch"), "main")
self.assertEqual(call_kwargs.get("cwd"), ".")
self.assertTrue(call_kwargs.get("force"))
# ------------------------------------------------------------------
# unknown subcommand
# ------------------------------------------------------------------
def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None:
"""