Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0482a7f88d | ||
|
|
8c127cc45a | ||
|
|
2761e829cb | ||
|
|
d0c01b6955 | ||
|
|
b2421c9b84 | ||
|
|
f950bb493c | ||
|
|
fb0b81954d | ||
|
|
b9b4c3fa59 | ||
|
|
3642f92776 | ||
|
|
8f38edde67 | ||
|
|
5875441b23 | ||
|
|
9190f0d901 | ||
|
|
f227734185 | ||
|
|
c7ef77559c | ||
|
|
2385601ed5 | ||
|
|
ac5ae95369 | ||
|
|
31f7f47fe2 | ||
|
|
c8bf1c91ad | ||
|
|
f2caa68e3d | ||
|
|
03c232c308 | ||
|
|
e882e17737 | ||
|
|
b9edcf7101 | ||
|
|
8b8ebf329f | ||
|
|
9598c17ea0 | ||
|
|
67bd358e12 | ||
|
|
340c1700dc | ||
|
|
0dfbaa0f6b | ||
|
|
08ab9fb142 | ||
|
|
804245325d | ||
|
|
c05e77658a | ||
|
|
324f6db1f3 | ||
|
|
2a69a83d71 | ||
|
|
0ec4ccbe40 | ||
|
|
0d864867cd | ||
|
|
3ff0afe828 | ||
|
|
bd74ad41f9 | ||
|
|
fa2a92481d | ||
|
|
6a1e001fc2 | ||
|
|
60afa92e09 | ||
|
|
212f3ce5eb | ||
|
|
0d79537033 | ||
|
|
72fc69c2f8 | ||
|
|
6d8c6deae8 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -13,8 +13,11 @@ jobs:
|
||||
test-integration:
|
||||
uses: ./.github/workflows/test-integration.yml
|
||||
|
||||
test-container:
|
||||
uses: ./.github/workflows/test-container.yml
|
||||
test-env-virtual:
|
||||
uses: ./.github/workflows/test-env-virtual.yml
|
||||
|
||||
test-env-nix:
|
||||
uses: ./.github/workflows/test-env-nix.yml
|
||||
|
||||
test-e2e:
|
||||
uses: ./.github/workflows/test-e2e.yml
|
||||
|
||||
10
.github/workflows/mark-stable.yml
vendored
10
.github/workflows/mark-stable.yml
vendored
@@ -14,8 +14,11 @@ jobs:
|
||||
test-integration:
|
||||
uses: ./.github/workflows/test-integration.yml
|
||||
|
||||
test-container:
|
||||
uses: ./.github/workflows/test-container.yml
|
||||
test-env-virtual:
|
||||
uses: ./.github/workflows/test-env-virtual.yml
|
||||
|
||||
test-env-nix:
|
||||
uses: ./.github/workflows/test-env-nix.yml
|
||||
|
||||
test-e2e:
|
||||
uses: ./.github/workflows/test-e2e.yml
|
||||
@@ -30,7 +33,8 @@ jobs:
|
||||
needs:
|
||||
- test-unit
|
||||
- test-integration
|
||||
- test-container
|
||||
- test-env-nix
|
||||
- test-env-virtual
|
||||
- test-e2e
|
||||
- test-virgin-user
|
||||
- test-virgin-root
|
||||
|
||||
56
.github/workflows/publish-containers.yml
vendored
Normal file
56
.github/workflows/publish-containers.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Publish container images (GHCR)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository (with tags)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Compute version and stable flag
|
||||
id: info
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
|
||||
STABLE_SHA="$(git rev-parse -q --verify refs/tags/stable^{commit} 2>/dev/null || true)"
|
||||
IS_STABLE=false
|
||||
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish all images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="${{ github.repository_owner }}" \
|
||||
VERSION="${{ steps.info.outputs.version }}" \
|
||||
IS_STABLE="${{ steps.info.outputs.is_stable }}" \
|
||||
bash scripts/build/publish.sh
|
||||
26
.github/workflows/test-env-nix.yml
vendored
Normal file
26
.github/workflows/test-env-nix.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Test Virgin Nix (flake only)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-env-nix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: [arch, debian, ubuntu, fedora, centos]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
- name: Nix flake-only test (${{ matrix.distro }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
distro="${{ matrix.distro }}" make test-env-nix
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-container:
|
||||
test-env-virtual:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -25,4 +25,4 @@ jobs:
|
||||
- name: Run container tests (${{ matrix.distro }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
distro="${{ matrix.distro }}" make test-container
|
||||
distro="${{ matrix.distro }}" make test-env-virtual
|
||||
38
.github/workflows/test-virgin-root.yml
vendored
38
.github/workflows/test-virgin-root.yml
vendored
@@ -7,6 +7,10 @@ jobs:
|
||||
test-virgin-root:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: [arch, debian, ubuntu, fedora, centos]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -15,44 +19,38 @@ jobs:
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
- name: Virgin Arch pkgmgr flake test (root)
|
||||
# 🔹 BUILD virgin image if missing
|
||||
- name: Build virgin container (${{ matrix.distro }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
distro="${{ matrix.distro }}" make build-missing-virgin
|
||||
|
||||
echo ">>> Starting virgin ArchLinux container test (root, with shared caches)..."
|
||||
# 🔹 RUN test inside virgin image
|
||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
-v pkgmgr_repos:/root/Repositories \
|
||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||
-w /src \
|
||||
archlinux:latest \
|
||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Updating and upgrading Arch system..."
|
||||
pacman -Syu --noconfirm git python python-pip nix >/dev/null
|
||||
git config --global --add safe.directory /src
|
||||
|
||||
echo ">>> Creating isolated virtual environment for pkgmgr..."
|
||||
python -m venv /tmp/pkgmgr-venv
|
||||
make install
|
||||
make setup
|
||||
|
||||
echo ">>> Activating virtual environment..."
|
||||
source /tmp/pkgmgr-venv/bin/activate
|
||||
. "$HOME/.venvs/pkgmgr/bin/activate"
|
||||
|
||||
echo ">>> Upgrading pip (cached)..."
|
||||
python -m pip install --upgrade pip >/dev/null
|
||||
|
||||
echo ">>> Installing pkgmgr from current source tree (cached pip)..."
|
||||
python -m pip install /src >/dev/null
|
||||
|
||||
echo ">>> Enabling Nix experimental features..."
|
||||
export NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
|
||||
echo ">>> Running: pkgmgr update pkgmgr --clone-mode shallow --no-verification"
|
||||
pkgmgr update pkgmgr --clone-mode shallow --no-verification
|
||||
|
||||
echo ">>> Running: pkgmgr version pkgmgr"
|
||||
pkgmgr version pkgmgr
|
||||
|
||||
echo ">>> Virgin Arch (root) test completed successfully."
|
||||
echo ">>> Running Nix-based: nix run .#pkgmgr -- version pkgmgr"
|
||||
nix run /src#pkgmgr -- version pkgmgr
|
||||
'
|
||||
|
||||
60
.github/workflows/test-virgin-user.yml
vendored
60
.github/workflows/test-virgin-user.yml
vendored
@@ -7,6 +7,10 @@ jobs:
|
||||
test-virgin-user:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: [arch, debian, ubuntu, fedora, centos]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -15,59 +19,47 @@ jobs:
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
- name: Virgin Arch pkgmgr user test (non-root with sudo)
|
||||
# 🔹 BUILD virgin image if missing
|
||||
- name: Build virgin container (${{ matrix.distro }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
distro="${{ matrix.distro }}" make build-missing-virgin
|
||||
|
||||
# 🔹 RUN test inside virgin image as non-root
|
||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Starting virgin ArchLinux container test (non-root user with sudo)..."
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
archlinux:latest \
|
||||
-w /src \
|
||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> [root] Updating and upgrading Arch system..."
|
||||
pacman -Syu --noconfirm git python python-pip sudo base-devel debugedit
|
||||
make install
|
||||
|
||||
echo ">>> [root] Creating non-root user dev..."
|
||||
useradd -m dev
|
||||
|
||||
echo ">>> [root] Allowing passwordless sudo for dev..."
|
||||
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
||||
chmod 0440 /etc/sudoers.d/dev
|
||||
|
||||
echo ">>> [root] Adjusting ownership of /src for dev..."
|
||||
chown -R dev:dev /src
|
||||
|
||||
echo ">>> [root] Running pkgmgr flow as non-root user dev..."
|
||||
sudo -u dev env PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
|
||||
mkdir -p /nix/store /nix/var/nix /nix/var/log/nix /nix/var/nix/profiles
|
||||
chown -R dev:dev /nix
|
||||
chmod 0755 /nix
|
||||
chmod 1777 /nix/store
|
||||
|
||||
sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
|
||||
set -euo pipefail
|
||||
cd /src
|
||||
|
||||
echo \">>> [dev] Using user: \$(whoami)\"
|
||||
echo \">>> [dev] Running scripts/installation/main.sh...\"
|
||||
bash scripts/installation/main.sh
|
||||
|
||||
echo \">>> [dev] Activating venv...\"
|
||||
make setup-venv
|
||||
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
|
||||
|
||||
echo \">>> [dev] Installing pkgmgr into venv via pip...\"
|
||||
python -m pip install /src >/dev/null
|
||||
|
||||
echo \">>> [dev] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=\$PKGMGR_DISABLE_NIX_FLAKE_INSTALLER\"
|
||||
echo \">>> [dev] Updating managed repo package-manager via pkgmgr...\"
|
||||
pkgmgr update pkgmgr --clone-mode shallow --no-verification
|
||||
|
||||
echo \">>> [dev] PATH:\"
|
||||
echo \"\$PATH\"
|
||||
|
||||
echo \">>> [dev] which pkgmgr:\"
|
||||
which pkgmgr || echo \">>> [dev] pkgmgr not found in PATH\"
|
||||
|
||||
echo \">>> [dev] Running: pkgmgr version pkgmgr\"
|
||||
pkgmgr version pkgmgr
|
||||
"
|
||||
|
||||
echo ">>> [root] Container flow finished."
|
||||
export NIX_REMOTE=local
|
||||
export NIX_CONFIG=\"experimental-features = nix-command flakes\"
|
||||
nix run /src#pkgmgr -- version pkgmgr
|
||||
"
|
||||
'
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,8 +27,9 @@ Thumbs.db
|
||||
# Nix Cache to speed up tests
|
||||
.nix/
|
||||
.nix-dev-installed
|
||||
flake.lock
|
||||
|
||||
# Ignore logs
|
||||
*.log
|
||||
|
||||
result
|
||||
result
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -1,3 +1,102 @@
|
||||
## [1.4.0] - 2025-12-12
|
||||
|
||||
* **Docker Container Building**
|
||||
|
||||
* New official container images are automatically published on each release.
|
||||
* Images are available per distribution and as a default Arch-based image.
|
||||
* Stable releases now provide an additional `stable` container tag.
|
||||
|
||||
|
||||
## [1.3.1] - 2025-12-12
|
||||
|
||||
* Updated documentation with better run and installation instructions
|
||||
|
||||
|
||||
## [1.3.0] - 2025-12-12
|
||||
|
||||
* **Minor release – Stability & CI hardening**
|
||||
|
||||
* Stabilized Nix resolution and global symlink handling across Arch, CentOS, Debian, and Ubuntu
|
||||
* Ensured Nix works reliably in CI, sudo, login, and non-login shells without overriding distro-managed paths
|
||||
* Improved error handling and deterministic behavior for non-root environments
|
||||
* Refactored Docker and CI workflows for reproducible multi-distro virgin tests
|
||||
* Made E2E tests more realistic by executing real CLI commands
|
||||
* Fixed Python compatibility and missing dependencies on affected distros
|
||||
|
||||
|
||||
## [1.2.1] - 2025-12-12
|
||||
|
||||
* **Changed**
|
||||
|
||||
* Split container tests into *virtualenv* and *Nix flake* environments to clearly separate Python and Nix responsibilities.
|
||||
|
||||
**Fixed**
|
||||
|
||||
* Fixed Nix installer permission issues when running under a different user in containers.
|
||||
* Improved reliability of post-install Nix initialization across all distro packages.
|
||||
|
||||
**CI**
|
||||
|
||||
* Replaced generic container tests with explicit environment checks.
|
||||
* Validate Nix availability via *nix flake* tests instead of Docker build-time side effects.
|
||||
|
||||
|
||||
## [1.2.0] - 2025-12-12
|
||||
|
||||
* **Release workflow overhaul**
|
||||
|
||||
* Introduced a fully structured release workflow with clear phases and safeguards
|
||||
* Added preview-first releases with explicit confirmation before execution
|
||||
* Automatic handling of *latest* tag when a release is the newest version
|
||||
* Optional branch closing after successful releases with interactive confirmation
|
||||
* Improved safety by syncing with remote before any changes
|
||||
* Clear separation of concerns (workflow, git handling, prompts, versioning)
|
||||
|
||||
|
||||
## [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.
|
||||
|
||||
85
Dockerfile
85
Dockerfile
@@ -1,61 +1,58 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Base image selector — overridden by Makefile
|
||||
# Base image selector — overridden by build args / Makefile
|
||||
# ------------------------------------------------------------
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && \
|
||||
cat /etc/os-release || true
|
||||
# ============================================================
|
||||
# Target: virgin
|
||||
# - installs distro deps (incl. make)
|
||||
# - no pkgmgr build
|
||||
# - no entrypoint
|
||||
# ============================================================
|
||||
FROM ${BASE_IMAGE} AS virgin
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Nix environment defaults
|
||||
#
|
||||
# Nix itself is installed by your system packages (via init-nix.sh).
|
||||
# Here we only define default configuration options.
|
||||
# ------------------------------------------------------------
|
||||
ENV NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Unprivileged user for Arch package build (makepkg)
|
||||
# ------------------------------------------------------------
|
||||
RUN useradd -m aur_builder || true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy scripts and install distro dependencies
|
||||
# ------------------------------------------------------------
|
||||
WORKDIR /build
|
||||
|
||||
# Copy only scripts first so dependency installation can run early
|
||||
COPY scripts/ scripts/
|
||||
RUN find scripts -type f -name '*.sh' -exec chmod +x {} \;
|
||||
# Copy scripts first so dependency installation can be cached
|
||||
COPY scripts/installation/ scripts/installation/
|
||||
|
||||
# Install distro-specific build dependencies (and AUR builder on Arch)
|
||||
RUN scripts/installation/run-dependencies.sh
|
||||
# Install distro-specific build dependencies (including make)
|
||||
RUN bash scripts/installation/dependencies.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Select distro-specific Docker entrypoint
|
||||
# ------------------------------------------------------------
|
||||
# Docker entrypoint (distro-agnostic, nutzt run-package.sh)
|
||||
# ------------------------------------------------------------
|
||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entry.sh
|
||||
# Virgin default
|
||||
CMD ["bash"]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Build and install distro-native package-manager package
|
||||
# via Makefile `install` target (calls scripts/installation/run-package.sh)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
# ============================================================
|
||||
# Target: full
|
||||
# - inherits from virgin
|
||||
# - builds + installs pkgmgr
|
||||
# - sets entrypoint + default cmd
|
||||
# ============================================================
|
||||
FROM virgin AS full
|
||||
|
||||
# Nix environment defaults (only config; nix itself comes from deps/install flow)
|
||||
ENV NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy full repository for build
|
||||
COPY . .
|
||||
RUN find scripts -type f -name '*.sh' -exec chmod +x {} \;
|
||||
|
||||
RUN set -e; \
|
||||
echo "Building and installing package-manager via make install..."; \
|
||||
make install; \
|
||||
rm -rf /build
|
||||
# Build and install distro-native package-manager package
|
||||
RUN set -euo pipefail; \
|
||||
echo "Building and installing package-manager via make install..."; \
|
||||
make install; \
|
||||
cd /; rm -rf /build
|
||||
|
||||
# Entry point
|
||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Runtime working directory and dev entrypoint
|
||||
# ------------------------------------------------------------
|
||||
WORKDIR /src
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||
CMD ["pkgmgr", "--help"]
|
||||
|
||||
74
Makefile
74
Makefile
@@ -1,9 +1,12 @@
|
||||
.PHONY: install setup uninstall \
|
||||
test build build-no-cache test-unit test-e2e test-integration \
|
||||
test-container
|
||||
.PHONY: install uninstall \
|
||||
build build-no-cache build-no-cache-all build-missing \
|
||||
delete-volumes purge \
|
||||
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
||||
setup setup-venv setup-nix
|
||||
|
||||
# Distro
|
||||
# Options: arch debian ubuntu fedora centos
|
||||
DISTROS ?= arch debian ubuntu fedora centos
|
||||
distro ?= arch
|
||||
export distro
|
||||
|
||||
@@ -29,19 +32,50 @@ TEST_PATTERN := test_*.py
|
||||
export TEST_PATTERN
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# PKGMGR setup (developer wrapper -> scripts/installation/main.sh)
|
||||
# System install
|
||||
# ------------------------------------------------------------
|
||||
setup:
|
||||
@bash scripts/installation/main.sh
|
||||
install:
|
||||
@echo "Building and installing distro-native package-manager for this system..."
|
||||
@bash scripts/installation/init.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# PKGMGR setup
|
||||
# ------------------------------------------------------------
|
||||
|
||||
# Default: keep current auto-detection behavior
|
||||
setup: setup-nix setup-venv
|
||||
|
||||
# Explicit: developer setup (Python venv + shell RC + main.py install)
|
||||
setup-venv: setup-nix
|
||||
@bash scripts/setup/venv.sh
|
||||
|
||||
# Explicit: Nix shell mode (no venv, no RC changes)
|
||||
setup-nix:
|
||||
@bash scripts/setup/nix.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Docker build targets (delegated to scripts/build)
|
||||
# ------------------------------------------------------------
|
||||
build-no-cache:
|
||||
@bash scripts/build/build-image-no-cache.sh
|
||||
|
||||
build:
|
||||
@bash scripts/build/build-image.sh
|
||||
@bash scripts/build/image.sh --target virgin
|
||||
@bash scripts/build/image.sh
|
||||
|
||||
build-missing-virgin:
|
||||
@bash scripts/build/image.sh --target virgin --missing
|
||||
|
||||
build-missing: build-missing-virgin
|
||||
@bash scripts/build/image.sh --missing
|
||||
|
||||
build-no-cache:
|
||||
@bash scripts/build/image.sh --target virgin --no-cache
|
||||
@bash scripts/build/image.sh --no-cache
|
||||
|
||||
build-no-cache-all:
|
||||
@set -e; \
|
||||
for d in $(DISTROS); do \
|
||||
echo "=== build-no-cache: $$d ==="; \
|
||||
distro="$$d" $(MAKE) build-no-cache; \
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Test targets (delegated to scripts/test)
|
||||
@@ -56,30 +90,20 @@ test-integration: build-missing
|
||||
test-e2e: build-missing
|
||||
@bash scripts/test/test-e2e.sh
|
||||
|
||||
test-container: build-missing
|
||||
@bash scripts/test/test-container.sh
|
||||
test-env-virtual: build-missing
|
||||
@bash scripts/test/test-env-virtual.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Build only missing container images
|
||||
# ------------------------------------------------------------
|
||||
build-missing:
|
||||
@bash scripts/build/build-image-missing.sh
|
||||
test-env-nix: build-missing
|
||||
@bash scripts/test/test-env-nix.sh
|
||||
|
||||
# Combined test target for local + CI (unit + integration + e2e)
|
||||
test: test-container test-unit test-integration test-e2e
|
||||
test: test-env-virtual test-unit test-integration test-e2e
|
||||
|
||||
delete-volumes:
|
||||
@docker volume rm pkgmgr_nix_store_${distro} pkgmgr_nix_cache_${distro} || true
|
||||
|
||||
purge: delete-volumes build-no-cache
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# System install (native packages, calls scripts/installation/run-package.sh)
|
||||
# ------------------------------------------------------------
|
||||
install:
|
||||
@echo "Building and installing distro-native package-manager for this system..."
|
||||
@bash scripts/installation/run-package.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Uninstall target
|
||||
# ------------------------------------------------------------
|
||||
|
||||
215
README.md
215
README.md
@@ -1,70 +1,215 @@
|
||||
# Package Manager🤖📦
|
||||
# Package Manager 🤖📦
|
||||
|
||||

|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](https://s.veen.world/paypaldonate)
|
||||
[](LICENSE)
|
||||
[](https://github.com/kevinveenbirkenbach/package-manager)
|
||||
[](https://github.com/kevinveenbirkenbach/package-manager/actions/workflows/mark-stable.yml)
|
||||
|
||||
*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)**](https://s.veen.world/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 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:** 12 December 2025
|
||||
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||
|
||||
---
|
||||
|
||||
Perfekt, dann hier die **noch kompaktere und korrekt differenzierte Version**, die **nur** zwischen
|
||||
**`make setup`** und **`make setup-venv`** unterscheidet und exakt deinem Verhalten entspricht.
|
||||
|
||||
README-ready, ohne Over-Engineering.
|
||||
|
||||
---
|
||||
|
||||
## Installation ⚙️
|
||||
|
||||
Clone the repository and ensure your `~/.local/bin` is in your system PATH:
|
||||
PKGMGR can be installed using `make`.
|
||||
The setup mode defines **which runtime layers are prepared**.
|
||||
|
||||
---
|
||||
|
||||
### Dependency installation (optional)
|
||||
|
||||
System dependencies required **before running any *make* commands** are installed via:
|
||||
|
||||
```
|
||||
scripts/installation/dependencies.sh
|
||||
```
|
||||
|
||||
The script detects and normalizes the OS and installs the required **system-level dependencies** accordingly.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Setup modes
|
||||
|
||||
| Command | Prepares | Use case |
|
||||
| ------------------- | ----------------------- | --------------------- |
|
||||
| **make setup** | Python venv **and** Nix | Full development & CI |
|
||||
| **make setup-venv** | Python venv only | Local user setup |
|
||||
|
||||
---
|
||||
|
||||
### Install & setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kevinveenbirkenbach/package-manager.git
|
||||
cd package-manager
|
||||
make install
|
||||
```
|
||||
|
||||
Install make and pip if not installed yet:
|
||||
|
||||
```bash
|
||||
pacman -S make python-pip
|
||||
```
|
||||
|
||||
Then, run the following command to set up the project:
|
||||
#### Full setup (venv + Nix)
|
||||
|
||||
```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.
|
||||
Use this for CI, servers, containers and full development workflows.
|
||||
|
||||
#### Venv-only setup
|
||||
|
||||
```bash
|
||||
make setup-venv
|
||||
source ~/.venvs/pkgmgr/bin/activate
|
||||
```
|
||||
|
||||
Use this if you want PKGMGR isolated without Nix integration.
|
||||
|
||||
---
|
||||
|
||||
## Run without installation (Nix)
|
||||
|
||||
Run PKGMGR directly via Nix Flakes.
|
||||
|
||||
```bash
|
||||
nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- --help
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- version pkgmgr
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* full flake URL required
|
||||
* `--` separates Nix and PKGMGR arguments
|
||||
* can be used alongside any setup mode
|
||||
|
||||
---
|
||||
|
||||
## Usage 🧰
|
||||
|
||||
After installation, the main entry point is:
|
||||
|
||||
```bash
|
||||
pkgmgr --help
|
||||
```
|
||||
|
||||
This prints a list of all available subcommands.
|
||||
The help for each command is available via:
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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.4.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -50,9 +50,10 @@ package() {
|
||||
install -Dm0755 "scripts/pkgmgr-wrapper.sh" \
|
||||
"$pkgdir/usr/bin/pkgmgr"
|
||||
|
||||
# Install Nix init helper
|
||||
install -Dm0755 "scripts/init-nix.sh" \
|
||||
"$pkgdir/usr/lib/package-manager/init-nix.sh"
|
||||
# Install Nix bootstrap (init + lib)
|
||||
install -d "$pkgdir/usr/lib/package-manager/nix"
|
||||
cp -a scripts/nix/* "$pkgdir/usr/lib/package-manager/nix/"
|
||||
chmod 0755 "$pkgdir/usr/lib/package-manager/nix/init.sh"
|
||||
|
||||
# Install the full repository into /usr/lib/package-manager
|
||||
mkdir -p "$pkgdir/usr/lib/package-manager"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
post_install() {
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
|
||||
@@ -3,11 +3,7 @@ set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
else
|
||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||
fi
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ override_dh_auto_test:
|
||||
:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install phase: copy wrapper + init script + full project source
|
||||
# Install phase: copy wrapper + Nix bootstrap (init + lib) + full project source
|
||||
# ---------------------------------------------------------------------------
|
||||
override_dh_auto_install:
|
||||
# Create target directories
|
||||
@@ -31,9 +31,11 @@ override_dh_auto_install:
|
||||
install -m0755 scripts/pkgmgr-wrapper.sh \
|
||||
debian/package-manager/usr/bin/pkgmgr
|
||||
|
||||
# Install shared Nix init script
|
||||
install -m0755 scripts/init-nix.sh \
|
||||
debian/package-manager/usr/lib/package-manager/init-nix.sh
|
||||
# Install Nix bootstrap (init + lib)
|
||||
install -d debian/package-manager/usr/lib/package-manager/nix
|
||||
cp -a scripts/nix/* \
|
||||
debian/package-manager/usr/lib/package-manager/nix/
|
||||
chmod 0755 debian/package-manager/usr/lib/package-manager/nix/init.sh
|
||||
|
||||
# Copy full project source into /usr/lib/package-manager,
|
||||
# but do not include the debian/ directory itself.
|
||||
|
||||
@@ -12,7 +12,7 @@ BuildArch: noarch
|
||||
# NOTE:
|
||||
# Nix is a runtime requirement, but it is *not* declared here as a hard
|
||||
# RPM dependency, because many distributions do not ship a "nix" RPM.
|
||||
# Instead, Nix is installed and initialized by init-nix.sh, which is
|
||||
# Instead, Nix is installed and initialized by nix/init.sh, which is
|
||||
# called in the %post scriptlet below.
|
||||
|
||||
%description
|
||||
@@ -22,7 +22,7 @@ manager via a local Nix flake:
|
||||
nix run /usr/lib/package-manager#pkgmgr -- ...
|
||||
|
||||
Nix is a runtime requirement and is installed/initialized by the
|
||||
init-nix.sh helper during package installation if it is not yet
|
||||
nix/init.sh helper during package installation if it is not yet
|
||||
available on the system.
|
||||
|
||||
%prep
|
||||
@@ -34,8 +34,8 @@ available on the system.
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
|
||||
install -d %{buildroot}%{_bindir}
|
||||
# Install project tree into a fixed, architecture-independent location.
|
||||
install -d %{buildroot}/usr/lib/package-manager
|
||||
|
||||
# Copy full project source into /usr/lib/package-manager
|
||||
@@ -44,8 +44,10 @@ cp -a . %{buildroot}/usr/lib/package-manager/
|
||||
# Wrapper
|
||||
install -m0755 scripts/pkgmgr-wrapper.sh %{buildroot}%{_bindir}/pkgmgr
|
||||
|
||||
# Shared Nix init script (ensure it is executable in the installed tree)
|
||||
install -m0755 scripts/init-nix.sh %{buildroot}/usr/lib/package-manager/init-nix.sh
|
||||
# Nix bootstrap (init + lib)
|
||||
install -d %{buildroot}/usr/lib/package-manager/nix
|
||||
cp -a scripts/nix/* %{buildroot}/usr/lib/package-manager/nix/
|
||||
chmod 0755 %{buildroot}/usr/lib/package-manager/nix/init.sh
|
||||
|
||||
# Remove packaging-only and development artefacts from the installed tree
|
||||
rm -rf \
|
||||
@@ -60,12 +62,7 @@ rm -rf \
|
||||
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
||||
|
||||
%post
|
||||
# Initialize Nix (if needed) after installing the package-manager files.
|
||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
else
|
||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||
fi
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
|
||||
%postun
|
||||
echo ">>> package-manager removed. Nix itself was not removed."
|
||||
|
||||
@@ -7,10 +7,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "package-manager"
|
||||
version = "0.10.2"
|
||||
version = "1.4.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${BASE_IMAGE_ARCH:=archlinux:latest}"
|
||||
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
||||
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
||||
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:stream9}"
|
||||
|
||||
resolve_base_image() {
|
||||
local distro="$1"
|
||||
|
||||
case "$distro" in
|
||||
arch) echo "$BASE_IMAGE_ARCH" ;;
|
||||
debian) echo "$BASE_IMAGE_DEBIAN" ;;
|
||||
ubuntu) echo "$BASE_IMAGE_UBUNTU" ;;
|
||||
fedora) echo "$BASE_IMAGE_FEDORA" ;;
|
||||
centos) echo "$BASE_IMAGE_CENTOS" ;;
|
||||
*)
|
||||
echo "ERROR: Unknown distro '$distro'" >&2
|
||||
exit 1
|
||||
;;
|
||||
*) echo "ERROR: Unknown distro '$distro'" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/resolve-base-image.sh"
|
||||
|
||||
IMAGE="package-manager-test-$distro"
|
||||
BASE_IMAGE="$(resolve_base_image "$distro")"
|
||||
|
||||
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "[build-missing] Image already exists: $IMAGE (skipping)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "------------------------------------------------------------"
|
||||
echo "[build-missing] Building missing image: $IMAGE"
|
||||
echo "BASE_IMAGE = $BASE_IMAGE"
|
||||
echo "------------------------------------------------------------"
|
||||
|
||||
docker build \
|
||||
--build-arg BASE_IMAGE="$BASE_IMAGE" \
|
||||
-t "$IMAGE" \
|
||||
.
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/resolve-base-image.sh"
|
||||
|
||||
base_image="$(resolve_base_image "$distro")"
|
||||
|
||||
echo ">>> Building test image for distro '$distro' with NO CACHE (BASE_IMAGE=$base_image)..."
|
||||
|
||||
docker build \
|
||||
--no-cache \
|
||||
--build-arg BASE_IMAGE="$base_image" \
|
||||
-t "package-manager-test-$distro" \
|
||||
.
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/resolve-base-image.sh"
|
||||
|
||||
base_image="$(resolve_base_image "$distro")"
|
||||
|
||||
echo ">>> Building test image for distro '$distro' (BASE_IMAGE=$base_image)..."
|
||||
|
||||
docker build \
|
||||
--build-arg BASE_IMAGE="$base_image" \
|
||||
-t "package-manager-test-$distro" \
|
||||
.
|
||||
225
scripts/build/image.sh
Executable file
225
scripts/build/image.sh
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/base.sh"
|
||||
|
||||
: "${distro:?Environment variable 'distro' must be set (arch|debian|ubuntu|fedora|centos)}"
|
||||
|
||||
NO_CACHE=0
|
||||
MISSING_ONLY=0
|
||||
TARGET=""
|
||||
IMAGE_TAG="" # local image name or base tag (without registry)
|
||||
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
|
||||
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
|
||||
REGISTRY="" # e.g. ghcr.io
|
||||
OWNER="" # e.g. github org/user
|
||||
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
|
||||
VERSION="" # X.Y.Z (required for --publish)
|
||||
IS_STABLE="false" # "true" -> publish stable tags
|
||||
DEFAULT_DISTRO="arch"
|
||||
|
||||
usage() {
|
||||
local default_tag="pkgmgr-${distro}"
|
||||
if [[ -n "${TARGET:-}" ]]; then
|
||||
default_tag="${default_tag}-${TARGET}"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
Usage: distro=<distro> $0 [options]
|
||||
|
||||
Build options:
|
||||
--missing Build only if the image does not already exist (local build only)
|
||||
--no-cache Build with --no-cache
|
||||
--target <name> Build a specific Dockerfile target (e.g. virgin)
|
||||
--tag <image> Override the output image tag (default: ${default_tag})
|
||||
|
||||
Publish options:
|
||||
--push Push the built image (uses docker buildx build --push)
|
||||
--publish Publish semantic tags (latest, <version>, optional stable) + arch aliases
|
||||
--registry <reg> Registry (e.g. ghcr.io)
|
||||
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
|
||||
--repo-prefix <name> Image base name (default: pkgmgr)
|
||||
--version <X.Y.Z> Version for --publish
|
||||
--stable <true|false> Whether to publish :stable tags (default: false)
|
||||
|
||||
Notes:
|
||||
- --publish implies --push and requires --registry, --owner, and --version.
|
||||
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin".
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-cache) NO_CACHE=1; shift ;;
|
||||
--missing) MISSING_ONLY=1; shift ;;
|
||||
--target)
|
||||
TARGET="${2:-}"
|
||||
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin)"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
IMAGE_TAG="${2:-}"
|
||||
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--push) PUSH=1; shift ;;
|
||||
--publish) PUBLISH=1; PUSH=1; shift ;;
|
||||
--registry)
|
||||
REGISTRY="${2:-}"
|
||||
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --registry requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--owner)
|
||||
OWNER="${2:-}"
|
||||
[[ -n "${OWNER}" ]] || { echo "ERROR: --owner requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--repo-prefix)
|
||||
REPO_PREFIX="${2:-}"
|
||||
[[ -n "${REPO_PREFIX}" ]] || { echo "ERROR: --repo-prefix requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
VERSION="${2:-}"
|
||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--stable)
|
||||
IS_STABLE="${2:-}"
|
||||
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Derive default local tag if not provided
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
IMAGE_TAG="${REPO_PREFIX}-${distro}"
|
||||
if [[ -n "${TARGET}" ]]; then
|
||||
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
|
||||
fi
|
||||
fi
|
||||
|
||||
BASE_IMAGE="$(resolve_base_image "$distro")"
|
||||
|
||||
# Local-only "missing" shortcut
|
||||
if [[ "${MISSING_ONLY}" == "1" ]]; then
|
||||
if [[ "${PUSH}" == "1" ]]; then
|
||||
echo "ERROR: --missing is only supported for local builds (without --push/--publish)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
|
||||
echo "[build] Image already exists: ${IMAGE_TAG} (skipping due to --missing)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate publish parameters
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
|
||||
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; }
|
||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
|
||||
fi
|
||||
|
||||
# Guard: --push without --publish requires fully-qualified --tag
|
||||
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
|
||||
if [[ "${IMAGE_TAG}" != */* ]]; then
|
||||
echo "ERROR: --push requires --tag with a fully-qualified name (e.g. ghcr.io/<owner>/<image>:tag), or use --publish" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "------------------------------------------------------------"
|
||||
echo "[build] Building image"
|
||||
echo "distro = ${distro}"
|
||||
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
||||
if [[ -n "${TARGET}" ]]; then echo "target = ${TARGET}"; fi
|
||||
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
||||
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
echo "publish = enabled"
|
||||
echo "registry = ${REGISTRY}"
|
||||
echo "owner = ${OWNER}"
|
||||
echo "version = ${VERSION}"
|
||||
echo "stable = ${IS_STABLE}"
|
||||
fi
|
||||
echo "------------------------------------------------------------"
|
||||
|
||||
# Common build args
|
||||
build_args=(--build-arg "BASE_IMAGE=${BASE_IMAGE}")
|
||||
|
||||
if [[ "${NO_CACHE}" == "1" ]]; then
|
||||
build_args+=(--no-cache)
|
||||
fi
|
||||
|
||||
if [[ -n "${TARGET}" ]]; then
|
||||
build_args+=(--target "${TARGET}")
|
||||
fi
|
||||
|
||||
compute_publish_tags() {
|
||||
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${distro}"
|
||||
local alias_tag_base=""
|
||||
|
||||
if [[ -n "${TARGET}" ]]; then
|
||||
distro_tag_base="${distro_tag_base}-${TARGET}"
|
||||
fi
|
||||
|
||||
if [[ "${distro}" == "${DEFAULT_DISTRO}" ]]; then
|
||||
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
||||
if [[ -n "${TARGET}" ]]; then
|
||||
alias_tag_base="${alias_tag_base}-${TARGET}"
|
||||
fi
|
||||
fi
|
||||
|
||||
local tags=()
|
||||
tags+=("${distro_tag_base}:latest")
|
||||
tags+=("${distro_tag_base}:${VERSION}")
|
||||
|
||||
if [[ "${IS_STABLE}" == "true" ]]; then
|
||||
tags+=("${distro_tag_base}:stable")
|
||||
fi
|
||||
|
||||
if [[ -n "${alias_tag_base}" ]]; then
|
||||
tags+=("${alias_tag_base}:latest")
|
||||
tags+=("${alias_tag_base}:${VERSION}")
|
||||
if [[ "${IS_STABLE}" == "true" ]]; then
|
||||
tags+=("${alias_tag_base}:stable")
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "${tags[@]}"
|
||||
}
|
||||
|
||||
if [[ "${PUSH}" == "1" ]]; then
|
||||
bx_args=(docker buildx build --push)
|
||||
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
while IFS= read -r t; do
|
||||
bx_args+=(-t "$t")
|
||||
done < <(compute_publish_tags)
|
||||
else
|
||||
bx_args+=(-t "${IMAGE_TAG}")
|
||||
fi
|
||||
|
||||
bx_args+=("${build_args[@]}")
|
||||
bx_args+=(.)
|
||||
|
||||
echo "[build] Running: ${bx_args[*]}"
|
||||
"${bx_args[@]}"
|
||||
else
|
||||
local_args=(docker build)
|
||||
local_args+=("${build_args[@]}")
|
||||
local_args+=(-t "${IMAGE_TAG}")
|
||||
local_args+=(.)
|
||||
|
||||
echo "[build] Running: ${local_args[*]}"
|
||||
"${local_args[@]}"
|
||||
fi
|
||||
55
scripts/build/publish.sh
Executable file
55
scripts/build/publish.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Publish all distro images (full + virgin) to a registry via image.sh --publish
|
||||
#
|
||||
# Required env:
|
||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||
# VERSION (e.g. 1.2.3)
|
||||
#
|
||||
# Optional env:
|
||||
# REGISTRY (default: ghcr.io)
|
||||
# IS_STABLE (default: false)
|
||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
REGISTRY="${REGISTRY:-ghcr.io}"
|
||||
IS_STABLE="${IS_STABLE:-false}"
|
||||
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
|
||||
|
||||
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
|
||||
: "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}"
|
||||
|
||||
echo "[publish] REGISTRY=${REGISTRY}"
|
||||
echo "[publish] OWNER=${OWNER}"
|
||||
echo "[publish] VERSION=${VERSION}"
|
||||
echo "[publish] IS_STABLE=${IS_STABLE}"
|
||||
echo "[publish] DISTROS=${DISTROS}"
|
||||
|
||||
for d in ${DISTROS}; do
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "[publish] distro=${d}"
|
||||
echo "============================================================"
|
||||
|
||||
# virgin
|
||||
distro="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--stable "${IS_STABLE}" \
|
||||
--target virgin
|
||||
|
||||
# full (default target)
|
||||
distro="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--stable "${IS_STABLE}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[publish] Done."
|
||||
@@ -1,53 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detect and export a valid CA bundle so Nix, Git, curl and Python tooling
|
||||
# can successfully perform HTTPS requests on all distros (Debian, Ubuntu,
|
||||
# Fedora, RHEL, CentOS, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
detect_ca_bundle() {
|
||||
# Common CA bundle locations across major Linux distributions
|
||||
local candidates=(
|
||||
/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
|
||||
/etc/ssl/cert.pem # Some distros
|
||||
/etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS
|
||||
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle
|
||||
/etc/ssl/ca-bundle.pem # Generic fallback
|
||||
)
|
||||
|
||||
for path in "${candidates[@]}"; do
|
||||
if [[ -f "$path" ]]; then
|
||||
echo "$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect
|
||||
CA_BUNDLE="${NIX_SSL_CERT_FILE:-}"
|
||||
|
||||
if [[ -z "${CA_BUNDLE}" ]]; then
|
||||
CA_BUNDLE="$(detect_ca_bundle || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "${CA_BUNDLE}" ]]; then
|
||||
# Export for Nix (critical)
|
||||
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
|
||||
# Export for Git, Python requests, curl, etc.
|
||||
export SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
|
||||
export GIT_SSL_CAINFO="${CA_BUNDLE}"
|
||||
|
||||
echo "[docker] Using CA bundle: ${CA_BUNDLE}"
|
||||
else
|
||||
echo "[docker] WARNING: No CA certificate bundle found."
|
||||
echo "[docker] HTTPS access for Nix flakes and other tools may fail."
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "[docker] Starting package-manager container"
|
||||
@@ -68,16 +21,10 @@ cd /src
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEV mode: rebuild package-manager from the mounted /src tree
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
|
||||
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
|
||||
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
|
||||
|
||||
if [[ -x scripts/installation/run-package.sh ]]; then
|
||||
bash scripts/installation/run-package.sh
|
||||
else
|
||||
echo "[docker] ERROR: scripts/installation/run-package.sh not found or not executable"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${REINSTALL_PKGMGR:-0}" == "1" ]]; then
|
||||
echo "[docker] DEV mode enabled (REINSTALL_PKGMGR=1)"
|
||||
echo "[docker] Rebuilding package-manager from /src via scripts/installation/package.sh..."
|
||||
bash scripts/installation/package.sh || exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detect whether we are inside a container (Docker/Podman/etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
is_container() {
|
||||
if [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qiE 'docker|container|podman|lxc' /proc/1/cgroup 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "${container:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure Nix binaries are on PATH (multi-user or single-user)
|
||||
# ---------------------------------------------------------------------------
|
||||
ensure_nix_on_path() {
|
||||
if [[ -x /nix/var/nix/profiles/default/bin/nix ]]; then
|
||||
export PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
||||
fi
|
||||
|
||||
if [[ -x "${HOME}/.nix-profile/bin/nix" ]]; then
|
||||
export PATH="${HOME}/.nix-profile/bin:${PATH}"
|
||||
fi
|
||||
|
||||
if [[ -x /home/nix/.nix-profile/bin/nix ]]; then
|
||||
export PATH="/home/nix/.nix-profile/bin:${PATH}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure Nix build group and users exist (build-users-group = nixbld)
|
||||
# ---------------------------------------------------------------------------
|
||||
ensure_nix_build_group() {
|
||||
if ! getent group nixbld >/dev/null 2>&1; then
|
||||
echo "[init-nix] Creating group 'nixbld'..."
|
||||
groupadd -r nixbld
|
||||
fi
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
if ! id "nixbld$i" >/dev/null 2>&1; then
|
||||
echo "[init-nix] Creating build user nixbld$i..."
|
||||
useradd -r -g nixbld -G nixbld -s /usr/sbin/nologin "nixbld$i"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download and run Nix installer with retry
|
||||
# Usage: install_nix_with_retry daemon|no-daemon [run_as_user]
|
||||
# ---------------------------------------------------------------------------
|
||||
install_nix_with_retry() {
|
||||
local mode="$1"
|
||||
local run_as="${2:-}"
|
||||
local installer elapsed=0 mode_flag
|
||||
|
||||
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
|
||||
|
||||
installer="$(mktemp -t nix-installer.XXXXXX)"
|
||||
|
||||
echo "[init-nix] Downloading Nix installer from ${NIX_INSTALL_URL} with retry (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
||||
|
||||
while true; do
|
||||
if curl -fL "${NIX_INSTALL_URL}" -o "${installer}"; then
|
||||
echo "[init-nix] Successfully downloaded Nix installer to ${installer}"
|
||||
break
|
||||
fi
|
||||
|
||||
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
|
||||
su - "${run_as}" -c "sh '${installer}' ${mode_flag}"
|
||||
fi
|
||||
else
|
||||
echo "[init-nix] Running installer as current user with mode '${mode}'..."
|
||||
sh "${installer}" "${mode_flag}"
|
||||
fi
|
||||
|
||||
rm -f "${installer}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 found after adjusting PATH: $(command -v nix)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[init-nix] Nix not found, starting installation logic..."
|
||||
|
||||
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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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)."
|
||||
|
||||
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
|
||||
|
||||
if [[ ! -d /nix ]]; then
|
||||
echo "[init-nix] Creating /nix with owner nix:nixbld..."
|
||||
mkdir -m 0755 /nix
|
||||
chown nix:nixbld /nix
|
||||
else
|
||||
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
|
||||
|
||||
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)
|
||||
# -------------------------------------------------------------------------
|
||||
else
|
||||
echo "[init-nix] Container as non-root – using single-user install (--no-daemon)."
|
||||
install_nix_with_retry "no-daemon"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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."
|
||||
else
|
||||
echo "[init-nix] Nix successfully installed at: $(command -v nix)"
|
||||
fi
|
||||
|
||||
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)
|
||||
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"
|
||||
fi
|
||||
|
||||
echo "[init-nix] Nix initialization complete."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -12,6 +12,7 @@ pacman -S --noconfirm --needed \
|
||||
rsync \
|
||||
curl \
|
||||
ca-certificates \
|
||||
python \
|
||||
xz
|
||||
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
@@ -1,30 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[arch/package] Building Arch package (makepkg --nodeps)..."
|
||||
echo "[arch/package] Building Arch package (makepkg --nodeps) in an isolated build dir..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
PKG_DIR="${PROJECT_ROOT}/packaging/arch"
|
||||
|
||||
if [[ ! -f "${PKG_DIR}/PKGBUILD" ]]; then
|
||||
echo "[arch/package] ERROR: PKGBUILD not found in ${PKG_DIR}"
|
||||
# We must not build inside /src (mounted repo). Build in /tmp to avoid permission issues.
|
||||
BUILD_ROOT="/tmp/package-manager-arch-build"
|
||||
PKG_SRC_DIR="${PROJECT_ROOT}/packaging/arch"
|
||||
PKG_BUILD_DIR="${BUILD_ROOT}/packaging/arch"
|
||||
|
||||
if [[ ! -f "${PKG_SRC_DIR}/PKGBUILD" ]]; then
|
||||
echo "[arch/package] ERROR: PKGBUILD not found in ${PKG_SRC_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${PKG_DIR}"
|
||||
echo "[arch/package] Preparing build directory: ${BUILD_ROOT}"
|
||||
rm -rf "${BUILD_ROOT}"
|
||||
mkdir -p "${BUILD_ROOT}"
|
||||
|
||||
if id aur_builder >/dev/null 2>&1; then
|
||||
echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
||||
chown -R aur_builder:aur_builder "${PKG_DIR}"
|
||||
su aur_builder -c "cd '${PKG_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
else
|
||||
echo "[arch/package] WARNING: user 'aur_builder' not found, running makepkg as current user..."
|
||||
rm -f package-manager-*.pkg.tar.*
|
||||
makepkg --noconfirm --clean --nodeps
|
||||
echo "[arch/package] Syncing project sources to ${BUILD_ROOT}..."
|
||||
# Keep it simple: copy everything; adjust excludes if needed later.
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude '.venv' \
|
||||
--exclude '.venvs' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
"${PROJECT_ROOT}/" "${BUILD_ROOT}/"
|
||||
|
||||
if [[ ! -d "${PKG_BUILD_DIR}" ]]; then
|
||||
echo "[arch/package] ERROR: Build PKG dir missing: ${PKG_BUILD_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Unprivileged user for Arch package build (makepkg)
|
||||
# ------------------------------------------------------------
|
||||
if ! id aur_builder >/dev/null 2>&1; then
|
||||
echo "[arch/package] ERROR: user 'aur_builder' not found. Run scripts/installation/arch/aur-builder-setup.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
||||
chown -R aur_builder:aur_builder "${BUILD_ROOT}"
|
||||
|
||||
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
|
||||
su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
|
||||
echo "[arch/package] Installing generated Arch package..."
|
||||
pacman -U --noconfirm package-manager-*.pkg.tar.*
|
||||
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
|
||||
if [[ -z "${pkg_path}" ]]; then
|
||||
echo "[arch/package] ERROR: Built package not found in ${PKG_BUILD_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pacman -U --noconfirm "${pkg_path}"
|
||||
|
||||
echo "[arch/package] Cleanup build directory..."
|
||||
rm -rf "${BUILD_ROOT}"
|
||||
|
||||
echo "[arch/package] Done."
|
||||
|
||||
@@ -13,9 +13,64 @@ dnf -y install \
|
||||
bash \
|
||||
curl-minimal \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
sudo \
|
||||
xz
|
||||
|
||||
dnf clean all
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Persist CA bundle configuration system-wide (virgin-compatible)
|
||||
# -----------------------------------------------------------------------------
|
||||
detect_ca_bundle() {
|
||||
local candidates=(
|
||||
/etc/pki/tls/certs/ca-bundle.crt
|
||||
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
|
||||
/etc/ssl/certs/ca-certificates.crt
|
||||
/etc/ssl/cert.pem
|
||||
/etc/ssl/ca-bundle.pem
|
||||
)
|
||||
|
||||
for path in "${candidates[@]}"; do
|
||||
if [[ -f "$path" ]]; then
|
||||
echo "$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
CA_BUNDLE="$(detect_ca_bundle || true)"
|
||||
|
||||
if [[ -n "${CA_BUNDLE}" ]]; then
|
||||
echo "[centos/dependencies] Persisting CA bundle: ${CA_BUNDLE}"
|
||||
|
||||
# 1) Make it available for login shells
|
||||
cat >/etc/profile.d/pkgmgr-ca.sh <<EOF
|
||||
# Generated by package-manager
|
||||
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
export SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
|
||||
export GIT_SSL_CAINFO="${CA_BUNDLE}"
|
||||
EOF
|
||||
chmod 0644 /etc/profile.d/pkgmgr-ca.sh
|
||||
|
||||
# 2) Ensure Nix uses it even without environment variables
|
||||
mkdir -p /etc/nix
|
||||
if [[ -f /etc/nix/nix.conf ]]; then
|
||||
# Replace existing ssl-cert-file or append it
|
||||
if grep -qE '^\s*ssl-cert-file\s*=' /etc/nix/nix.conf; then
|
||||
sed -i "s|^\s*ssl-cert-file\s*=.*|ssl-cert-file = ${CA_BUNDLE}|" /etc/nix/nix.conf
|
||||
else
|
||||
echo "ssl-cert-file = ${CA_BUNDLE}" >>/etc/nix/nix.conf
|
||||
fi
|
||||
else
|
||||
echo "ssl-cert-file = ${CA_BUNDLE}" >/etc/nix/nix.conf
|
||||
fi
|
||||
|
||||
else
|
||||
echo "[centos/dependencies] WARNING: No CA bundle found after installing ca-certificates."
|
||||
fi
|
||||
|
||||
echo "[centos/dependencies] Done."
|
||||
|
||||
@@ -13,6 +13,8 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
curl \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
python3-venv \
|
||||
xz-utils
|
||||
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -3,22 +3,19 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${SCRIPT_DIR}/lib.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "${SCRIPT_DIR}/os_resolver.sh"
|
||||
|
||||
OS_ID="$(detect_os_id)"
|
||||
OS_ID="$(osr_get_os_id)"
|
||||
|
||||
echo "[run-dependencies] Detected OS: ${OS_ID}"
|
||||
|
||||
case "${OS_ID}" in
|
||||
arch|debian|ubuntu|fedora|centos)
|
||||
DEP_SCRIPT="${SCRIPT_DIR}/${OS_ID}/dependencies.sh"
|
||||
;;
|
||||
*)
|
||||
echo "[run-dependencies] Unsupported OS: ${OS_ID}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! osr_is_supported "${OS_ID}"; then
|
||||
echo "[run-dependencies] Unsupported OS: ${OS_ID}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEP_SCRIPT="$(osr_script_path_for "${SCRIPT_DIR}" "${OS_ID}" "dependencies")"
|
||||
|
||||
if [[ ! -f "${DEP_SCRIPT}" ]]; then
|
||||
echo "[run-dependencies] Dependency script not found: ${DEP_SCRIPT}"
|
||||
15
scripts/installation/init.sh
Executable file
15
scripts/installation/init.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "[installation/install] Warning: Installation is just possible via root."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[installation] Running as root (EUID=0)."
|
||||
echo "[installation] Install Package Dependencies..."
|
||||
bash scripts/installation/dependencies.sh
|
||||
echo "[installation] Install Distribution Package..."
|
||||
bash scripts/installation/package.sh
|
||||
echo "[installation] Root/system setup complete."
|
||||
exit 0
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
detect_os_id() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "${ID:-unknown}"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# main.sh
|
||||
#
|
||||
# Developer / system setup entrypoint.
|
||||
#
|
||||
# Responsibilities:
|
||||
# - If inside a Nix shell (IN_NIX_SHELL=1):
|
||||
# * Skip venv creation and dependency installation
|
||||
# * Run `python3 main.py install`
|
||||
# - If running as root (EUID=0):
|
||||
# * Run system-level installer (run-package.sh)
|
||||
# - Otherwise (normal user):
|
||||
# * Create ~/.venvs/pkgmgr virtual environment if missing
|
||||
# * Install Python dependencies into that venv
|
||||
# * Append auto-activation to ~/.bashrc and ~/.zshrc
|
||||
# * Run `main.py install` using the venv Python
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[installation/main] Starting setup..."
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
VENV_DIR="${HOME}/.venvs/pkgmgr"
|
||||
RC_LINE='if [ -d "${HOME}/.venvs/pkgmgr" ]; then . "${HOME}/.venvs/pkgmgr/bin/activate"; if [ -n "${PS1:-}" ]; then echo "Global Python virtual environment '\''~/.venvs/pkgmgr'\'' activated."; fi; fi'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 1) Nix shell mode: do not touch venv, only run main.py install
|
||||
# ------------------------------------------------------------
|
||||
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
|
||||
echo "[installation/main] Nix shell detected (IN_NIX_SHELL=1)."
|
||||
echo "[installation/main] Skipping virtualenv creation and dependency installation."
|
||||
echo "[installation/main] Running main.py install via system python3..."
|
||||
python3 main.py install
|
||||
echo "[installation/main] Setup finished (Nix mode)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 2) Root mode: system / distro-level installation
|
||||
# ------------------------------------------------------------
|
||||
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
||||
echo "[installation/main] Running as root (EUID=0)."
|
||||
echo "[installation/main] Skipping user virtualenv and shell RC modifications."
|
||||
echo "[installation/main] Delegating to scripts/installation/run-package.sh..."
|
||||
bash scripts/installation/run-package.sh
|
||||
echo "[installation/main] Root/system setup complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 3) Normal user mode: dev setup with venv
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[installation/main] Running in normal user mode (developer setup)."
|
||||
|
||||
echo "[installation/main] Ensuring main.py is executable..."
|
||||
chmod +x main.py || true
|
||||
|
||||
echo "[installation/main] Ensuring global virtualenv root: ${HOME}/.venvs"
|
||||
mkdir -p "${HOME}/.venvs"
|
||||
|
||||
echo "[installation/main] Creating/updating virtualenv via helper..."
|
||||
PKGMGR_VENV_DIR="${VENV_DIR}" bash scripts/installation/venv-create.sh
|
||||
|
||||
echo "[installation/main] Ensuring ~/.bashrc and ~/.zshrc exist..."
|
||||
touch "${HOME}/.bashrc" "${HOME}/.zshrc"
|
||||
|
||||
echo "[installation/main] Ensuring venv auto-activation is present in shell rc files..."
|
||||
for rc in "${HOME}/.bashrc" "${HOME}/.zshrc"; do
|
||||
if ! grep -qxF "${RC_LINE}" "$rc"; then
|
||||
echo "${RC_LINE}" >> "$rc"
|
||||
echo "[installation/main] Appended auto-activation to $rc"
|
||||
else
|
||||
echo "[installation/main] Auto-activation already present in $rc"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[installation/main] Running main.py install via venv Python..."
|
||||
"${VENV_DIR}/bin/python" main.py install
|
||||
|
||||
echo
|
||||
echo "[installation/main] Developer setup complete."
|
||||
echo "Restart your shell (or run 'exec bash' or 'exec zsh') to activate the environment."
|
||||
82
scripts/installation/os_resolver.sh
Executable file
82
scripts/installation/os_resolver.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OsResolver (bash "class-style" module)
|
||||
# Centralizes OS detection + normalization + supported checks + script paths.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
osr_detect_raw_id() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "${ID:-unknown}"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
osr_detect_id_like() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "${ID_LIKE:-}"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
osr_normalize_id() {
|
||||
local raw="${1:-unknown}"
|
||||
local like="${2:-}"
|
||||
|
||||
# Explicit mapping first (your bugfix: manjaro -> arch everywhere)
|
||||
case "${raw}" in
|
||||
manjaro) echo "arch"; return 0 ;;
|
||||
esac
|
||||
|
||||
# Keep direct IDs when they are already supported
|
||||
case "${raw}" in
|
||||
arch|debian|ubuntu|fedora|centos) echo "${raw}"; return 0 ;;
|
||||
esac
|
||||
|
||||
# Fallback mapping via ID_LIKE for better portability
|
||||
# Example: many Arch derivatives expose ID_LIKE="arch"
|
||||
if [[ " ${like} " == *" arch "* ]]; then
|
||||
echo "arch"; return 0
|
||||
fi
|
||||
if [[ " ${like} " == *" debian "* ]]; then
|
||||
echo "debian"; return 0
|
||||
fi
|
||||
if [[ " ${like} " == *" fedora "* ]]; then
|
||||
echo "fedora"; return 0
|
||||
fi
|
||||
if [[ " ${like} " == *" rhel "* || " ${like} " == *" centos "* ]]; then
|
||||
echo "centos"; return 0
|
||||
fi
|
||||
|
||||
echo "${raw}"
|
||||
}
|
||||
|
||||
osr_get_os_id() {
|
||||
local raw like
|
||||
raw="$(osr_detect_raw_id)"
|
||||
like="$(osr_detect_id_like)"
|
||||
osr_normalize_id "${raw}" "${like}"
|
||||
}
|
||||
|
||||
osr_is_supported() {
|
||||
local id="${1:-unknown}"
|
||||
case "${id}" in
|
||||
arch|debian|ubuntu|fedora|centos) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
osr_script_path_for() {
|
||||
local script_dir="${1:?script_dir required}"
|
||||
local os_id="${2:?os_id required}"
|
||||
local kind="${3:?kind required}" # "dependencies" or "package"
|
||||
|
||||
echo "${script_dir}/${os_id}/${kind}.sh"
|
||||
}
|
||||
26
scripts/installation/package.sh
Executable file
26
scripts/installation/package.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${SCRIPT_DIR}/os_resolver.sh"
|
||||
|
||||
OS_ID="$(osr_get_os_id)"
|
||||
|
||||
echo "[package] Detected OS: ${OS_ID}"
|
||||
|
||||
if ! osr_is_supported "${OS_ID}"; then
|
||||
echo "[package] Unsupported OS: ${OS_ID}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PKG_SCRIPT="$(osr_script_path_for "${SCRIPT_DIR}" "${OS_ID}" "package")"
|
||||
|
||||
if [[ ! -f "${PKG_SCRIPT}" ]]; then
|
||||
echo "[package] Package script not found: ${PKG_SCRIPT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[package] Executing: ${PKG_SCRIPT}"
|
||||
exec bash "${PKG_SCRIPT}"
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${SCRIPT_DIR}/lib.sh"
|
||||
|
||||
OS_ID="$(detect_os_id)"
|
||||
|
||||
# Map Manjaro to Arch
|
||||
if [[ "${OS_ID}" == "manjaro" ]]; then
|
||||
echo "[run-package] Mapping OS 'manjaro' → 'arch'"
|
||||
OS_ID="arch"
|
||||
fi
|
||||
|
||||
echo "[run-package] Detected OS: ${OS_ID}"
|
||||
|
||||
case "${OS_ID}" in
|
||||
arch|debian|ubuntu|fedora|centos)
|
||||
PKG_SCRIPT="${SCRIPT_DIR}/${OS_ID}/package.sh"
|
||||
;;
|
||||
*)
|
||||
echo "[run-package] Unsupported OS: ${OS_ID}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -f "${PKG_SCRIPT}" ]]; then
|
||||
echo "[run-package] Package script not found: ${PKG_SCRIPT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[run-package] Executing: ${PKG_SCRIPT}"
|
||||
exec bash "${PKG_SCRIPT}"
|
||||
@@ -14,6 +14,9 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
rsync \
|
||||
bash \
|
||||
curl \
|
||||
make \
|
||||
python3 \
|
||||
python3-venv \
|
||||
ca-certificates \
|
||||
xz-utils
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# venv-create.sh
|
||||
#
|
||||
# Small helper to create/update a Python virtual environment for pkgmgr.
|
||||
#
|
||||
# Usage:
|
||||
# PKGMGR_VENV_DIR=/home/dev/.venvs/pkgmgr bash scripts/installation/venv-create.sh
|
||||
# or
|
||||
# bash scripts/installation/venv-create.sh /home/dev/.venvs/pkgmgr
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
VENV_DIR="${PKGMGR_VENV_DIR:-${1:-${HOME}/.venvs/pkgmgr}}"
|
||||
|
||||
echo "[venv-create] Using VENV_DIR=${VENV_DIR}"
|
||||
|
||||
echo "[venv-create] Ensuring virtualenv parent directory exists..."
|
||||
mkdir -p "$(dirname "${VENV_DIR}")"
|
||||
|
||||
if [[ ! -d "${VENV_DIR}" ]]; then
|
||||
echo "[venv-create] Creating virtual environment at: ${VENV_DIR}"
|
||||
python3 -m venv "${VENV_DIR}"
|
||||
else
|
||||
echo "[venv-create] Virtual environment already exists at: ${VENV_DIR}"
|
||||
fi
|
||||
|
||||
echo "[venv-create] Installing Python tooling into venv..."
|
||||
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
||||
|
||||
if [[ -f "requirements.txt" ]]; then
|
||||
echo "[venv-create] Installing dependencies from requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r requirements.txt
|
||||
elif [[ -f "_requirements.txt" ]]; then
|
||||
echo "[venv-create] Installing dependencies from _requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r _requirements.txt
|
||||
else
|
||||
echo "[venv-create] No requirements.txt or _requirements.txt found. Skipping dependency installation."
|
||||
fi
|
||||
|
||||
echo "[venv-create] Done."
|
||||
53
scripts/nix/README.md
Normal file
53
scripts/nix/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Nix Bootstrap (package-manager)
|
||||
|
||||
This directory contains the **Nix initialization and bootstrap logic** used by *package-manager* to ensure the `nix` command is available on supported systems (host machines and CI containers).
|
||||
|
||||
It is invoked during package installation (Arch/Debian/Fedora scriptlets) and can also be called manually.
|
||||
|
||||
---
|
||||
|
||||
## Entry Point
|
||||
|
||||
- *scripts/nix/init.sh*
|
||||
Main bootstrap script. It:
|
||||
- checks whether `nix` is already available
|
||||
- adjusts `PATH` for common Nix locations
|
||||
- installs Nix when missing (daemon install on systemd hosts, single-user in containers)
|
||||
- ensures predictable `nix` availability via symlinks (without overwriting distro-managed paths)
|
||||
- validates that `nix` is usable at the end (CI-safe)
|
||||
|
||||
---
|
||||
|
||||
## Library Layout
|
||||
|
||||
The entry point sources small, focused modules from *scripts/nix/lib/*:
|
||||
|
||||
- *config.sh* — configuration defaults (installer URL, retry timing)
|
||||
- *detect.sh* — container detection helpers
|
||||
- *path.sh* — PATH adjustments and `nix` binary resolution helpers
|
||||
- *symlinks.sh* — user/global symlink helpers for stable `nix` discovery
|
||||
- *users.sh* — build group/users and container ownership/perms helpers
|
||||
- *install.sh* — installer download + retry logic and execution helpers
|
||||
|
||||
Each library file includes a simple guard to prevent double-sourcing.
|
||||
|
||||
---
|
||||
|
||||
## When It Runs
|
||||
|
||||
This bootstrap is typically executed automatically:
|
||||
|
||||
- Arch: post-install / post-upgrade hook
|
||||
- Debian: `postinst`
|
||||
- Fedora/RPM: `%post`
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Notes / Design Goals
|
||||
|
||||
- **Cross-distro compatibility:** supports common Linux layouts (including Arch placing `nix` in */usr/sbin*).
|
||||
- **Non-destructive behavior:** avoids overwriting distro-managed `nix` binaries.
|
||||
- **CI robustness:** retry logic for downloads and a final `nix` availability check.
|
||||
- **Container-safe defaults:** single-user install as a dedicated `nix` user when running as root in containers.
|
||||
|
||||
130
scripts/nix/init.sh
Executable file
130
scripts/nix/init.sh
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=lib/config.sh
|
||||
# shellcheck source=lib/detect.sh
|
||||
# shellcheck source=lib/path.sh
|
||||
# shellcheck source=lib/symlinks.sh
|
||||
# shellcheck source=lib/users.sh
|
||||
# shellcheck source=lib/install.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "${SCRIPT_DIR}/lib/config.sh"
|
||||
source "${SCRIPT_DIR}/lib/detect.sh"
|
||||
source "${SCRIPT_DIR}/lib/path.sh"
|
||||
source "${SCRIPT_DIR}/lib/symlinks.sh"
|
||||
source "${SCRIPT_DIR}/lib/users.sh"
|
||||
source "${SCRIPT_DIR}/lib/install.sh"
|
||||
|
||||
echo "[init-nix] Starting Nix initialization..."
|
||||
|
||||
main() {
|
||||
# Fast path: already available
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
echo "[init-nix] Nix already available on PATH: $(command -v nix)"
|
||||
ensure_nix_on_path
|
||||
|
||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||
ensure_global_nix_symlinks "$(resolve_nix_bin 2>/dev/null || true)"
|
||||
else
|
||||
ensure_user_nix_symlink "$(resolve_nix_bin 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_nix_on_path
|
||||
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
echo "[init-nix] Nix found after PATH adjustment: $(command -v nix)"
|
||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||
ensure_global_nix_symlinks "$(resolve_nix_bin 2>/dev/null || true)"
|
||||
else
|
||||
ensure_user_nix_symlink "$(resolve_nix_bin 2>/dev/null || true)"
|
||||
fi
|
||||
return 0
|
||||
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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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)."
|
||||
|
||||
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
|
||||
|
||||
ensure_nix_store_dir_for_container_user
|
||||
|
||||
install_nix_with_retry "no-daemon" "nix"
|
||||
|
||||
ensure_nix_on_path
|
||||
|
||||
# Ensure stable global symlink(s) (sudo secure_path friendly)
|
||||
ensure_global_nix_symlinks "/home/nix/.nix-profile/bin/nix"
|
||||
|
||||
# Ensure non-root users can traverse and execute nix user profile
|
||||
ensure_container_profile_perms
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host (no container)
|
||||
# -------------------------------------------------------------------------
|
||||
else
|
||||
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
|
||||
echo "[init-nix] No systemd detected: using single-user install (--no-daemon)."
|
||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||
ensure_nix_build_group
|
||||
fi
|
||||
install_nix_with_retry "no-daemon"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# After install: PATH + symlink(s)
|
||||
# -------------------------------------------------------------------------
|
||||
ensure_nix_on_path
|
||||
|
||||
local nix_bin_post
|
||||
nix_bin_post="$(resolve_nix_bin 2>/dev/null || true)"
|
||||
|
||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||
ensure_global_nix_symlinks "$nix_bin_post"
|
||||
else
|
||||
ensure_user_nix_symlink "$nix_bin_post"
|
||||
fi
|
||||
|
||||
# Final verification (must succeed for CI)
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "[init-nix] ERROR: nix not found after installation."
|
||||
echo "[init-nix] DEBUG: resolved nix path = ${nix_bin_post:-<empty>}"
|
||||
echo "[init-nix] DEBUG: PATH = $PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[init-nix] Nix successfully available at: $(command -v nix)"
|
||||
echo "[init-nix] Nix initialization complete."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
11
scripts/nix/lib/config.sh
Executable file
11
scripts/nix/lib/config.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Prevent double-sourcing
|
||||
if [[ -n "${PKGMGR_NIX_CONFIG_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_CONFIG_SH=1
|
||||
|
||||
NIX_INSTALL_URL="${NIX_INSTALL_URL:-https://nixos.org/nix/install}"
|
||||
NIX_DOWNLOAD_MAX_TIME="${NIX_DOWNLOAD_MAX_TIME:-300}"
|
||||
NIX_DOWNLOAD_SLEEP_INTERVAL="${NIX_DOWNLOAD_SLEEP_INTERVAL:-20}"
|
||||
14
scripts/nix/lib/detect.sh
Executable file
14
scripts/nix/lib/detect.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${PKGMGR_NIX_DETECT_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_DETECT_SH=1
|
||||
|
||||
# Detect whether we are inside a container (Docker/Podman/etc.)
|
||||
is_container() {
|
||||
[[ -f /.dockerenv || -f /run/.containerenv ]] && return 0
|
||||
grep -qiE 'docker|container|podman|lxc' /proc/1/cgroup 2>/dev/null && return 0
|
||||
[[ -n "${container:-}" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
63
scripts/nix/lib/install.sh
Executable file
63
scripts/nix/lib/install.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${PKGMGR_NIX_INSTALL_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_INSTALL_SH=1
|
||||
|
||||
# Requires: NIX_INSTALL_URL, NIX_DOWNLOAD_MAX_TIME, NIX_DOWNLOAD_SLEEP_INTERVAL
|
||||
|
||||
# Download and run Nix installer with retry
|
||||
# Usage: install_nix_with_retry daemon|no-daemon [run_as_user]
|
||||
install_nix_with_retry() {
|
||||
local mode="$1"
|
||||
local run_as="${2:-}"
|
||||
local installer elapsed=0 mode_flag
|
||||
|
||||
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
|
||||
|
||||
installer="$(mktemp -t nix-installer.XXXXXX)"
|
||||
chmod 0644 "$installer"
|
||||
|
||||
echo "[init-nix] Downloading Nix installer from $NIX_INSTALL_URL (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
||||
|
||||
while true; do
|
||||
if curl -fL "$NIX_INSTALL_URL" -o "$installer"; then
|
||||
echo "[init-nix] Successfully downloaded installer to $installer"
|
||||
break
|
||||
fi
|
||||
|
||||
elapsed=$((elapsed + NIX_DOWNLOAD_SLEEP_INTERVAL))
|
||||
echo "[init-nix] WARNING: Download failed. Retrying in ${NIX_DOWNLOAD_SLEEP_INTERVAL}s (elapsed ${elapsed}s)..."
|
||||
|
||||
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
|
||||
|
||||
sleep "$NIX_DOWNLOAD_SLEEP_INTERVAL"
|
||||
done
|
||||
|
||||
if [[ -n "$run_as" ]]; then
|
||||
chown "$run_as:$run_as" "$installer" 2>/dev/null || true
|
||||
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
|
||||
else
|
||||
su - "$run_as" -c "sh '$installer' $mode_flag"
|
||||
fi
|
||||
else
|
||||
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
||||
sh "$installer" "$mode_flag"
|
||||
fi
|
||||
|
||||
rm -f "$installer"
|
||||
}
|
||||
68
scripts/nix/lib/path.sh
Executable file
68
scripts/nix/lib/path.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${PKGMGR_NIX_PATH_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_PATH_SH=1
|
||||
|
||||
# Ensure Nix binaries are on PATH (additive, never destructive)
|
||||
ensure_nix_on_path() {
|
||||
if [[ -x /nix/var/nix/profiles/default/bin/nix ]]; then
|
||||
PATH="/nix/var/nix/profiles/default/bin:$PATH"
|
||||
fi
|
||||
if [[ -x "$HOME/.nix-profile/bin/nix" ]]; then
|
||||
PATH="$HOME/.nix-profile/bin:$PATH"
|
||||
fi
|
||||
if [[ -x /home/nix/.nix-profile/bin/nix ]]; then
|
||||
PATH="/home/nix/.nix-profile/bin:$PATH"
|
||||
fi
|
||||
if [[ -d "$HOME/.local/bin" ]]; then
|
||||
PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
export PATH
|
||||
}
|
||||
|
||||
# Resolve a path to a real executable (follows symlinks)
|
||||
real_exe() {
|
||||
local p="${1:-}"
|
||||
[[ -z "$p" ]] && return 1
|
||||
|
||||
local r
|
||||
r="$(readlink -f "$p" 2>/dev/null || echo "$p")"
|
||||
|
||||
[[ -x "$r" ]] && { echo "$r"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
|
||||
resolve_nix_bin() {
|
||||
local nix_cmd=""
|
||||
nix_cmd="$(command -v nix 2>/dev/null || true)"
|
||||
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
|
||||
|
||||
# IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
|
||||
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
|
||||
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
|
||||
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
|
||||
|
||||
# /usr/local last, and only if it resolves to a real executable
|
||||
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
|
||||
|
||||
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
|
||||
echo "/nix/var/nix/profiles/default/bin/nix"; return 0;
|
||||
}
|
||||
|
||||
[[ -x "$HOME/.nix-profile/bin/nix" ]] && {
|
||||
echo "$HOME/.nix-profile/bin/nix"; return 0;
|
||||
}
|
||||
|
||||
[[ -x "$HOME/.local/bin/nix" ]] && {
|
||||
echo "$HOME/.local/bin/nix"; return 0;
|
||||
}
|
||||
|
||||
[[ -x /home/nix/.nix-profile/bin/nix ]] && {
|
||||
echo "/home/nix/.nix-profile/bin/nix"; return 0;
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
95
scripts/nix/lib/symlinks.sh
Executable file
95
scripts/nix/lib/symlinks.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${PKGMGR_NIX_SYMLINKS_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_SYMLINKS_SH=1
|
||||
|
||||
# Requires: real_exe, resolve_nix_bin
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
# Ensure globally reachable nix symlink(s) (CI / non-login shells) - root only
|
||||
ensure_global_nix_symlinks() {
|
||||
local nix_bin="${1:-}"
|
||||
|
||||
[[ -z "$nix_bin" ]] && nix_bin="$(resolve_nix_bin 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$nix_bin" || ! -x "$nix_bin" ]]; then
|
||||
echo "[init-nix] WARNING: nix binary not found, cannot create global symlink(s)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Always link to the real executable to avoid /usr/local/bin/nix -> /usr/local/bin/nix
|
||||
nix_bin="$(real_exe "$nix_bin" 2>/dev/null || echo "$nix_bin")"
|
||||
|
||||
local targets=()
|
||||
|
||||
# Always provide /usr/local/bin/nix for CI shells
|
||||
mkdir -p /usr/local/bin 2>/dev/null || true
|
||||
targets+=("/usr/local/bin/nix")
|
||||
|
||||
# Provide sudo-friendly locations only if they are NOT present (do not override distro paths)
|
||||
if [[ ! -e /usr/bin/nix ]]; then
|
||||
targets+=("/usr/bin/nix")
|
||||
fi
|
||||
if [[ ! -e /usr/sbin/nix ]]; then
|
||||
targets+=("/usr/sbin/nix")
|
||||
fi
|
||||
|
||||
local target current_real
|
||||
for target in "${targets[@]}"; do
|
||||
current_real=""
|
||||
if [[ -e "$target" ]]; then
|
||||
current_real="$(real_exe "$target" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$current_real" && "$current_real" == "$nix_bin" ]]; then
|
||||
echo "[init-nix] $target already points to: $nix_bin"
|
||||
continue
|
||||
fi
|
||||
|
||||
# If something exists but is not the same (and we promised not to override), skip.
|
||||
if [[ -e "$target" && "$target" != "/usr/local/bin/nix" ]]; then
|
||||
echo "[init-nix] WARNING: $target exists; not overwriting."
|
||||
continue
|
||||
fi
|
||||
|
||||
if ln -sf "$nix_bin" "$target" 2>/dev/null; then
|
||||
echo "[init-nix] Ensured $target -> $nix_bin"
|
||||
else
|
||||
echo "[init-nix] WARNING: Failed to ensure $target symlink."
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure user-level nix symlink (works without root; CI-safe)
|
||||
ensure_user_nix_symlink() {
|
||||
local nix_bin="${1:-}"
|
||||
|
||||
[[ -z "$nix_bin" ]] && nix_bin="$(resolve_nix_bin 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$nix_bin" || ! -x "$nix_bin" ]]; then
|
||||
echo "[init-nix] WARNING: nix binary not found, cannot create user symlink."
|
||||
return 0
|
||||
fi
|
||||
|
||||
nix_bin="$(real_exe "$nix_bin" 2>/dev/null || echo "$nix_bin")"
|
||||
|
||||
mkdir -p "$HOME/.local/bin" 2>/dev/null || true
|
||||
ln -sf "$nix_bin" "$HOME/.local/bin/nix"
|
||||
|
||||
echo "[init-nix] Ensured $HOME/.local/bin/nix -> $nix_bin"
|
||||
|
||||
PATH="$HOME/.local/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
if [[ -w "$HOME/.profile" ]] && ! grep -q 'nix/init.sh' "$HOME/.profile" 2>/dev/null; then
|
||||
cat >>"$HOME/.profile" <<'EOF'
|
||||
|
||||
# PATH for nix (added by package-manager nix/init.sh)
|
||||
if [ -d "$HOME/.local/bin" ]; then
|
||||
PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
49
scripts/nix/lib/users.sh
Executable file
49
scripts/nix/lib/users.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${PKGMGR_NIX_USERS_SH:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
PKGMGR_NIX_USERS_SH=1
|
||||
|
||||
# Ensure Nix build group and users exist (build-users-group = nixbld) - root only
|
||||
ensure_nix_build_group() {
|
||||
if ! getent group nixbld >/dev/null 2>&1; then
|
||||
echo "[init-nix] Creating group 'nixbld'..."
|
||||
groupadd -r nixbld
|
||||
fi
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
if ! id "nixbld$i" >/dev/null 2>&1; then
|
||||
echo "[init-nix] Creating build user nixbld$i..."
|
||||
useradd -r -g nixbld -G nixbld -s /usr/sbin/nologin "nixbld$i"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Container-only helper: /nix ownership + perms for single-user install as 'nix'
|
||||
ensure_nix_store_dir_for_container_user() {
|
||||
if [[ ! -d /nix ]]; then
|
||||
echo "[init-nix] Creating /nix with owner nix:nixbld..."
|
||||
mkdir -m 0755 /nix
|
||||
chown nix:nixbld /nix
|
||||
return 0
|
||||
fi
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# Container-only helper: make nix profile executable/traversable for non-root
|
||||
ensure_container_profile_perms() {
|
||||
if [[ -d /home/nix ]]; then
|
||||
chmod o+rx /home/nix 2>/dev/null || true
|
||||
fi
|
||||
if [[ -d /home/nix/.nix-profile ]]; then
|
||||
chmod -R o+rx /home/nix/.nix-profile 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -28,11 +28,11 @@ if ! command -v nix >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# If nix is still missing, try to run init-nix.sh once
|
||||
# If nix is still missing, try to run nix/init.sh once
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
if [[ -x "${FLAKE_DIR}/init-nix.sh" ]]; then
|
||||
"${FLAKE_DIR}/init-nix.sh" || true
|
||||
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
|
||||
"${FLAKE_DIR}/nix/init.sh" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
9
scripts/setup/nix.sh
Executable file
9
scripts/setup/nix.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
# ------------------------------------------------------------
|
||||
# Nix shell mode: do not touch venv, only run main.py install
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[setup] Nix mode enabled (NIX_ENABLED=1)."
|
||||
echo "[setup] Skipping virtualenv creation and dependency installation."
|
||||
echo "[setup] Running main.py install via system python3..."
|
||||
python3 main.py install
|
||||
echo "[setup] Setup finished (Nix mode)."
|
||||
98
scripts/setup/venv.sh
Executable file
98
scripts/setup/venv.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[setup] Starting setup..."
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
VENV_DIR="${HOME}/.venvs/pkgmgr"
|
||||
RC_LINE='if [ -d "${HOME}/.venvs/pkgmgr" ]; then . "${HOME}/.venvs/pkgmgr/bin/activate"; if [ -n "${PS1:-}" ]; then echo "Global Python virtual environment '\''~/.venvs/pkgmgr'\'' activated."; fi; fi'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Normal user mode: dev setup with venv
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[setup] Running in normal user mode (developer setup)."
|
||||
|
||||
echo "[setup] Ensuring main.py is executable..."
|
||||
chmod +x main.py || true
|
||||
|
||||
echo "[setup] Ensuring global virtualenv root: ${HOME}/.venvs"
|
||||
mkdir -p "${HOME}/.venvs"
|
||||
|
||||
echo "[setup] Creating/updating virtualenv via helper..."
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
PIP_EDITABLE="${PKGMGR_PIP_EDITABLE:-1}"
|
||||
PIP_EXTRAS="${PKGMGR_PIP_EXTRAS:-}"
|
||||
PREFER_NIX="${PKGMGR_PREFER_NIX:-0}"
|
||||
|
||||
echo "[venv] Using VENV_DIR=${VENV_DIR}"
|
||||
|
||||
if [[ "${PREFER_NIX}" == "1" ]]; then
|
||||
echo "[venv] PKGMGR_PREFER_NIX=1 set."
|
||||
echo "[venv] Hint: Use Nix instead of a venv for reproducible installs:"
|
||||
echo "[venv] nix develop"
|
||||
echo "[venv] nix run .#pkgmgr -- --help"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[venv] Ensuring virtualenv parent directory exists..."
|
||||
mkdir -p "$(dirname "${VENV_DIR}")"
|
||||
|
||||
if [[ ! -d "${VENV_DIR}" ]]; then
|
||||
echo "[venv] Creating virtual environment at: ${VENV_DIR}"
|
||||
python3 -m venv "${VENV_DIR}"
|
||||
else
|
||||
echo "[venv] Virtual environment already exists at: ${VENV_DIR}"
|
||||
fi
|
||||
|
||||
echo "[venv] Installing Python tooling into venv..."
|
||||
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -f "pyproject.toml" ]]; then
|
||||
echo "[venv] Detected pyproject.toml. Installing project via pip..."
|
||||
|
||||
target="."
|
||||
if [[ -n "${PIP_EXTRAS}" ]]; then
|
||||
target=".[${PIP_EXTRAS}]"
|
||||
fi
|
||||
|
||||
if [[ "${PIP_EDITABLE}" == "1" ]]; then
|
||||
echo "[venv] pip install -e ${target}"
|
||||
"${VENV_DIR}/bin/pip" install -e "${target}"
|
||||
else
|
||||
echo "[venv] pip install ${target}"
|
||||
"${VENV_DIR}/bin/pip" install "${target}"
|
||||
fi
|
||||
else
|
||||
echo "[venv] No pyproject.toml found. Skipping dependency installation."
|
||||
fi
|
||||
|
||||
echo "[venv] Done."
|
||||
|
||||
echo "[setup] Ensuring ~/.bashrc and ~/.zshrc exist..."
|
||||
touch "${HOME}/.bashrc" "${HOME}/.zshrc"
|
||||
|
||||
echo "[setup] Ensuring venv auto-activation is present in shell rc files..."
|
||||
for rc in "${HOME}/.bashrc" "${HOME}/.zshrc"; do
|
||||
if ! grep -qxF "${RC_LINE}" "$rc"; then
|
||||
echo "${RC_LINE}" >> "$rc"
|
||||
echo "[setup] Appended auto-activation to $rc"
|
||||
else
|
||||
echo "[setup] Auto-activation already present in $rc"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[setup] Running main.py install via venv Python..."
|
||||
"${VENV_DIR}/bin/python" main.py install
|
||||
|
||||
echo
|
||||
echo "[setup] Developer setup complete."
|
||||
echo "Restart your shell (or run 'exec bash' or 'exec zsh') to activate the environment."
|
||||
@@ -9,10 +9,10 @@ docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_store_${distro}:/nix" \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e REINSTALL_PKGMGR=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
--workdir /src \
|
||||
"package-manager-test-${distro}" \
|
||||
"pkgmgr-${distro}" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
48
scripts/test/test-env-nix.sh
Executable file
48
scripts/test/test-env-nix.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="pkgmgr-${distro}"
|
||||
|
||||
echo "============================================================"
|
||||
echo ">>> Running Nix flake-only test in ${distro} container"
|
||||
echo ">>> Image: ${IMAGE}"
|
||||
echo "============================================================"
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_store_${distro}:/nix" \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
--workdir /src \
|
||||
-e REINSTALL_PKGMGR=1 \
|
||||
"${IMAGE}" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
git config --global --add safe.directory /src || true
|
||||
git config --global --add safe.directory /src/.git || true
|
||||
git config --global --add safe.directory "*" || true
|
||||
fi
|
||||
|
||||
echo ">>> preflight: nix must exist in image"
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "NO_NIX"
|
||||
echo "ERROR: nix not found in image '\'''"${IMAGE}"''\'' (distro='"${distro}"')"
|
||||
echo "HINT: Ensure Nix is installed during image build for this distro."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> nix version"
|
||||
nix --version
|
||||
|
||||
echo ">>> nix flake show"
|
||||
nix flake show . --no-write-lock-file >/dev/null
|
||||
|
||||
echo ">>> nix build .#default"
|
||||
nix build .#default --no-link --no-write-lock-file
|
||||
|
||||
echo ">>> nix run .#pkgmgr -- --help"
|
||||
nix run .#pkgmgr -- --help --no-write-lock-file
|
||||
|
||||
echo ">>> OK: Nix flake-only test succeeded."
|
||||
'
|
||||
@@ -1,32 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="package-manager-test-$distro"
|
||||
IMAGE="pkgmgr-$distro"
|
||||
|
||||
echo
|
||||
echo "------------------------------------------------------------"
|
||||
echo ">>> Testing container: $IMAGE"
|
||||
echo ">>> Testing VENV: $IMAGE"
|
||||
echo "------------------------------------------------------------"
|
||||
echo "[test-container] Inspect image metadata:"
|
||||
echo "[test-env-virtual] Inspect image metadata:"
|
||||
docker image inspect "$IMAGE" | sed -n '1,40p'
|
||||
|
||||
echo "[test-container] Running: docker run --rm --entrypoint pkgmgr $IMAGE --help"
|
||||
echo "[test-env-virtual] Running: docker run --rm --entrypoint pkgmgr $IMAGE --help"
|
||||
echo
|
||||
|
||||
# Run the command and capture the output
|
||||
if OUTPUT=$(docker run --rm \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e REINSTALL_PKGMGR=1 \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
"$IMAGE" 2>&1); then
|
||||
echo "$OUTPUT"
|
||||
echo
|
||||
echo "[test-container] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
||||
echo "[test-env-virtual] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
||||
|
||||
else
|
||||
echo "$OUTPUT"
|
||||
echo
|
||||
echo "[test-container] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
||||
echo "[test-env-virtual] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,9 +10,9 @@ docker run --rm \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
--workdir /src \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e REINSTALL_PKGMGR=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
"package-manager-test-${distro}" \
|
||||
"pkgmgr-${distro}" \
|
||||
bash -lc '
|
||||
set -e;
|
||||
git config --global --add safe.directory /src || true;
|
||||
|
||||
@@ -10,9 +10,9 @@ docker run --rm \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
--workdir /src \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e REINSTALL_PKGMGR=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
"package-manager-test-${distro}" \
|
||||
"pkgmgr-${distro}" \
|
||||
bash -lc '
|
||||
set -e;
|
||||
git config --global --add safe.directory /src || true;
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
99
src/pkgmgr/actions/branch/close_branch.py
Normal file
99
src/pkgmgr/actions/branch/close_branch.py
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
55
src/pkgmgr/actions/branch/drop_branch.py
Normal file
55
src/pkgmgr/actions/branch/drop_branch.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
64
src/pkgmgr/actions/branch/open_branch.py
Normal file
64
src/pkgmgr/actions/branch/open_branch.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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."
|
||||
)
|
||||
@@ -15,7 +15,7 @@ Responsibilities:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
@@ -63,7 +63,7 @@ def _ensure_repo_dir(
|
||||
no_verification: bool,
|
||||
clone_mode: str,
|
||||
identifier: str,
|
||||
) -> str | None:
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Compute and, if necessary, clone the repository directory.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
||||
import glob
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Iterable, TYPE_CHECKING
|
||||
from typing import Iterable, TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
@@ -46,7 +46,7 @@ if TYPE_CHECKING:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_text_if_exists(path: str) -> str | None:
|
||||
def _read_text_if_exists(path: str) -> Optional[str]:
|
||||
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
@@ -75,7 +75,7 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
|
||||
return False
|
||||
|
||||
|
||||
def _first_spec_file(repo_dir: str) -> str | None:
|
||||
def _first_spec_file(repo_dir: str) -> Optional[str]:
|
||||
"""Return the first *.spec file in repo_dir, if any."""
|
||||
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
||||
if not matches:
|
||||
@@ -360,7 +360,7 @@ def detect_capabilities(
|
||||
|
||||
def resolve_effective_capabilities(
|
||||
ctx: "RepoContext",
|
||||
layers: Iterable[str] | None = None,
|
||||
layers: Optional[Iterable[str]] = None,
|
||||
) -> dict[str, set[str]]:
|
||||
"""
|
||||
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
||||
|
||||
@@ -6,7 +6,7 @@ Base interface for all installer components in the pkgmgr installation pipeline.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Set
|
||||
from typing import Set, Optional
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||
@@ -24,7 +24,7 @@ class BaseInstaller(ABC):
|
||||
# Examples: "nix", "python", "makefile".
|
||||
# This is used by capability matchers to decide which patterns to
|
||||
# search for in the repository.
|
||||
layer: str | None = None
|
||||
layer: Optional[str] = None
|
||||
|
||||
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,7 @@ apt/dpkg tooling are available.
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
@@ -67,7 +67,7 @@ class DebianControlInstaller(BaseInstaller):
|
||||
pattern = os.path.join(parent, "*.deb")
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
def _privileged_prefix(self) -> str | None:
|
||||
def _privileged_prefix(self) -> Optional[str]:
|
||||
"""
|
||||
Determine how to run privileged commands:
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.git import GitError, run_git
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||
|
||||
|
||||
218
src/pkgmgr/actions/release/README.md
Normal file
218
src/pkgmgr/actions/release/README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Release Action
|
||||
|
||||
This module implements the `pkgmgr release` workflow.
|
||||
|
||||
It provides a controlled, reproducible release process that:
|
||||
- bumps the project version
|
||||
- updates all supported packaging formats
|
||||
- creates and pushes Git tags
|
||||
- optionally maintains a floating `latest` tag
|
||||
- optionally closes the current branch
|
||||
|
||||
The implementation is intentionally explicit and conservative to avoid
|
||||
accidental releases or broken Git states.
|
||||
|
||||
---
|
||||
|
||||
## What the Release Command Does
|
||||
|
||||
A release performs the following high-level steps:
|
||||
|
||||
1. Synchronize the current branch with its upstream (fast-forward only)
|
||||
2. Determine the next semantic version
|
||||
3. Update all versioned files
|
||||
4. Commit the release
|
||||
5. Create and push a version tag
|
||||
6. Optionally update and push the floating `latest` tag
|
||||
7. Optionally close the current branch
|
||||
|
||||
All steps support **preview (dry-run)** mode.
|
||||
|
||||
---
|
||||
|
||||
## Supported Files Updated During a Release
|
||||
|
||||
If present, the following files are updated automatically:
|
||||
|
||||
- `pyproject.toml`
|
||||
- `CHANGELOG.md`
|
||||
- `flake.nix`
|
||||
- `PKGBUILD`
|
||||
- `package-manager.spec`
|
||||
- `debian/changelog`
|
||||
|
||||
Missing files are skipped gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Git Safety Rules
|
||||
|
||||
The release workflow enforces strict Git safety guarantees:
|
||||
|
||||
- A `git pull --ff-only` is executed **before any file modifications**
|
||||
- No merge commits are ever created automatically
|
||||
- Only the current branch and the newly created version tag are pushed
|
||||
- `git push --tags` is intentionally **not** used
|
||||
- The floating `latest` tag is force-pushed only when required
|
||||
|
||||
---
|
||||
|
||||
## Semantic Versioning
|
||||
|
||||
The next version is calculated from existing Git tags:
|
||||
|
||||
- Tags must follow the format `vX.Y.Z`
|
||||
- The release type controls the version bump:
|
||||
- `patch`
|
||||
- `minor`
|
||||
- `major`
|
||||
|
||||
The new tag is always created as an **annotated tag**.
|
||||
|
||||
---
|
||||
|
||||
## Floating `latest` Tag
|
||||
|
||||
The floating `latest` tag is handled explicitly:
|
||||
|
||||
- `latest` is updated **only if** the new version is the highest existing version
|
||||
- Version comparison uses natural version sorting (`sort -V`)
|
||||
- `latest` always points to the commit behind the version tag
|
||||
- Updating `latest` uses a forced push by design
|
||||
|
||||
This guarantees that `latest` always represents the highest released version,
|
||||
never an older release.
|
||||
|
||||
---
|
||||
|
||||
## Preview Mode
|
||||
|
||||
Preview mode (`--preview`) performs a full dry-run:
|
||||
|
||||
- No files are modified
|
||||
- No Git commands are executed
|
||||
- All intended actions are printed
|
||||
|
||||
Example preview output includes:
|
||||
- version bump
|
||||
- file updates
|
||||
- commit message
|
||||
- tag creation
|
||||
- branch and tag pushes
|
||||
- `latest` update (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Interactive vs Forced Mode
|
||||
|
||||
### Interactive (default)
|
||||
|
||||
1. Run a preview
|
||||
2. Ask for confirmation
|
||||
3. Execute the real release
|
||||
|
||||
### Forced (`--force`)
|
||||
|
||||
- Skips preview and confirmation
|
||||
- Skips branch deletion prompts
|
||||
- Executes the release immediately
|
||||
|
||||
---
|
||||
|
||||
## Branch Closing (`--close`)
|
||||
|
||||
When `--close` is enabled:
|
||||
|
||||
- `main` and `master` are **never** deleted
|
||||
- Other branches:
|
||||
- prompt for confirmation (`y/N`)
|
||||
- can be skipped using `--force`
|
||||
- Branch deletion happens **only after** a successful release
|
||||
|
||||
---
|
||||
|
||||
## Execution Flow (ASCII Diagram)
|
||||
|
||||
```
|
||||
|
||||
+---------------------+
|
||||
| pkgmgr release |
|
||||
+----------+----------+
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| Detect branch |
|
||||
+----------+----------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| git fetch / pull --ff-only |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Determine next version |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Update versioned files |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Commit release |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Create version tag (vX.Y.Z) |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Push branch + version tag |
|
||||
+----------+-------------------+
|
||||
|
|
||||
v
|
||||
+---------------------------------------+
|
||||
| Is this the highest version? |
|
||||
+----------+----------------------------+
|
||||
|
|
||||
yes | no
|
||||
|
|
||||
v
|
||||
+------------------------------+ +----------------------+
|
||||
| Update & push `latest` tag | | Skip `latest` update |
|
||||
+----------+-------------------+ +----------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------+
|
||||
| Close branch (optional) |
|
||||
+------------------------------+
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
- Deterministic and reproducible releases
|
||||
- No implicit Git side effects
|
||||
- Explicit tag handling
|
||||
- Safe defaults for interactive usage
|
||||
- Automation-friendly forced mode
|
||||
- Clear separation of concerns:
|
||||
- `workflow.py` – orchestration
|
||||
- `git_ops.py` – Git operations
|
||||
- `prompts.py` – user interaction
|
||||
- `versioning.py` – SemVer logic
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
`pkgmgr release` is a **deliberately strict** release mechanism.
|
||||
|
||||
It trades convenience for safety, traceability, and correctness — making it
|
||||
suitable for both interactive development workflows and fully automated CI/CD
|
||||
@@ -1,310 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Release helper for pkgmgr (public entry point).
|
||||
|
||||
This package provides the high-level `release()` function used by the
|
||||
pkgmgr CLI to perform versioned releases:
|
||||
|
||||
- Determine the next semantic version based on existing Git tags.
|
||||
- Update pyproject.toml with the new version.
|
||||
- Update additional packaging files (flake.nix, PKGBUILD,
|
||||
debian/changelog, RPM spec) where present.
|
||||
- Prepend a basic entry to CHANGELOG.md.
|
||||
- Move the floating 'latest' tag to the newly created release tag so
|
||||
the newest release is always marked as latest.
|
||||
|
||||
Additional behaviour:
|
||||
- If `preview=True` (from --preview), no files are written and no
|
||||
Git commands are executed. Instead, a detailed summary of the
|
||||
planned changes and commands is printed.
|
||||
- If `preview=False` and not forced, the release is executed in two
|
||||
phases:
|
||||
1) Preview-only run (dry-run).
|
||||
2) Interactive confirmation, then real release if confirmed.
|
||||
This confirmation can be skipped with the `force=True` flag.
|
||||
- Before creating and pushing tags, main/master is updated from origin
|
||||
when the release is performed on one of these branches.
|
||||
- If `close=True` is used and the current branch is not main/master,
|
||||
the branch will be closed via branch_commands.close_branch() after
|
||||
a successful release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git import get_current_branch, GitError
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
|
||||
from .versioning import determine_current_version, bump_semver
|
||||
from .git_ops import run_git_command, sync_branch_with_remote, update_latest_tag
|
||||
from .files import (
|
||||
update_pyproject_version,
|
||||
update_flake_version,
|
||||
update_pkgbuild_version,
|
||||
update_spec_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_spec_changelog,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal implementation (single-phase, preview or real)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Internal implementation that performs a single-phase release.
|
||||
"""
|
||||
current_ver = determine_current_version()
|
||||
new_ver = bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
|
||||
mode = "PREVIEW" if preview else "REAL"
|
||||
print(f"Release mode: {mode}")
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
|
||||
# Update core project metadata and packaging files
|
||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||
changelog_message = update_changelog(
|
||||
changelog_path,
|
||||
new_ver_str,
|
||||
message=message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
flake_path = os.path.join(repo_root, "flake.nix")
|
||||
update_flake_version(flake_path, new_ver_str, preview=preview)
|
||||
|
||||
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
||||
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
||||
|
||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||
|
||||
# Determine a single effective_message to be reused across all
|
||||
# changelog targets (project, Debian, Fedora).
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
|
||||
# Debian changelog
|
||||
update_debian_changelog(
|
||||
debian_changelog_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
# Fedora / RPM %changelog
|
||||
update_spec_changelog(
|
||||
spec_path=spec_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
# Determine branch and ensure it is up to date if main/master
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitError:
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
# Ensure main/master are up-to-date from origin before creating and
|
||||
# pushing tags. For other branches we only log the intent.
|
||||
sync_branch_with_remote(branch, preview=preview)
|
||||
|
||||
files_to_add = [
|
||||
pyproject_path,
|
||||
changelog_path,
|
||||
flake_path,
|
||||
pkgbuild_path,
|
||||
spec_path,
|
||||
debian_changelog_path,
|
||||
]
|
||||
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
||||
|
||||
if preview:
|
||||
for path in existing_files:
|
||||
print(f"[PREVIEW] Would run: git add {path}")
|
||||
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||
print("[PREVIEW] Would run: git push origin --tags")
|
||||
|
||||
# Also update the floating 'latest' tag to the new highest SemVer.
|
||||
update_latest_tag(new_tag, preview=True)
|
||||
|
||||
if close and branch not in ("main", "master"):
|
||||
print(
|
||||
f"[PREVIEW] Would also close branch {branch} after the release "
|
||||
"(close=True and branch is not main/master)."
|
||||
)
|
||||
elif close:
|
||||
print(
|
||||
f"[PREVIEW] close=True but current branch is {branch}; "
|
||||
"no branch would be closed."
|
||||
)
|
||||
|
||||
print("Preview completed. No changes were made.")
|
||||
return
|
||||
|
||||
for path in existing_files:
|
||||
run_git_command(f"git add {path}")
|
||||
|
||||
run_git_command(f'git commit -am "{commit_msg}"')
|
||||
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
||||
run_git_command(f"git push origin {branch}")
|
||||
run_git_command("git push origin --tags")
|
||||
|
||||
# Move 'latest' to the new release tag so the newest SemVer is always
|
||||
# marked as latest. This is best-effort and must not break the release.
|
||||
try:
|
||||
update_latest_tag(new_tag, preview=False)
|
||||
except GitError as exc: # pragma: no cover
|
||||
print(
|
||||
f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}\n"
|
||||
"[WARN] The release itself completed successfully; only the "
|
||||
"'latest' tag was not updated."
|
||||
)
|
||||
|
||||
print(f"Release {new_ver_str} completed.")
|
||||
|
||||
if close:
|
||||
if branch in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] close=True but current branch is {branch}; "
|
||||
"nothing to close."
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[INFO] Closing branch {branch} after successful release "
|
||||
"(close=True and branch is not main/master)..."
|
||||
)
|
||||
try:
|
||||
close_branch(name=branch, base_branch="main", cwd=".")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public release entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
High-level release entry point.
|
||||
|
||||
Modes:
|
||||
|
||||
- preview=True:
|
||||
* Single-phase PREVIEW only.
|
||||
|
||||
- preview=False, force=True:
|
||||
* Single-phase REAL release, no interactive preview.
|
||||
|
||||
- preview=False, force=False:
|
||||
* Two-phase flow (intended default for interactive CLI use).
|
||||
"""
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if force:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
print("[INFO] Running preview before actual release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
|
||||
try:
|
||||
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n[INFO] Release aborted (no confirmation).")
|
||||
return
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
print("Release aborted by user. No changes were made.")
|
||||
return
|
||||
|
||||
print("\n[INFO] Running REAL release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
|
||||
from .workflow import release
|
||||
|
||||
__all__ = ["release"]
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Git-related helpers for the release workflow.
|
||||
|
||||
Responsibilities:
|
||||
- Run Git (or shell) commands with basic error reporting.
|
||||
- Ensure main/master are synchronized with origin before tagging.
|
||||
- Maintain the floating 'latest' tag that always points to the newest
|
||||
release tag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
@@ -19,77 +6,87 @@ from pkgmgr.core.git import GitError
|
||||
|
||||
|
||||
def run_git_command(cmd: str) -> None:
|
||||
"""
|
||||
Run a Git (or shell) command with basic error reporting.
|
||||
|
||||
The command is executed via the shell, primarily for readability
|
||||
when printed (as in 'git commit -am "msg"').
|
||||
"""
|
||||
print(f"[GIT] {cmd}")
|
||||
try:
|
||||
subprocess.run(cmd, shell=True, check=True)
|
||||
subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"[ERROR] Git command failed: {cmd}")
|
||||
print(f" Exit code: {exc.returncode}")
|
||||
if exc.stdout:
|
||||
print("--- stdout ---")
|
||||
print(exc.stdout)
|
||||
print("\n" + exc.stdout)
|
||||
if exc.stderr:
|
||||
print("--- stderr ---")
|
||||
print(exc.stderr)
|
||||
print("\n" + exc.stderr)
|
||||
raise GitError(f"Git command failed: {cmd}") from exc
|
||||
|
||||
|
||||
def sync_branch_with_remote(branch: str, preview: bool = False) -> None:
|
||||
"""
|
||||
Ensure the local main/master branch is up-to-date before tagging.
|
||||
def _capture(cmd: str) -> str:
|
||||
res = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
return (res.stdout or "").strip()
|
||||
|
||||
Behaviour:
|
||||
- For main/master: run 'git fetch origin' and 'git pull origin <branch>'.
|
||||
- For all other branches: only log that no automatic sync is performed.
|
||||
|
||||
def ensure_clean_and_synced(preview: bool = False) -> None:
|
||||
"""
|
||||
if branch not in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] Skipping automatic git pull for non-main/master branch "
|
||||
f"{branch}."
|
||||
)
|
||||
Always run a pull BEFORE modifying anything.
|
||||
Uses --ff-only to avoid creating merge commits automatically.
|
||||
If no upstream is configured, we skip.
|
||||
"""
|
||||
upstream = _capture("git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null")
|
||||
if not upstream:
|
||||
print("[INFO] No upstream configured for current branch. Skipping pull.")
|
||||
return
|
||||
|
||||
print(
|
||||
f"[INFO] Updating branch {branch} from origin before creating tags..."
|
||||
)
|
||||
|
||||
if preview:
|
||||
print("[PREVIEW] Would run: git fetch origin")
|
||||
print(f"[PREVIEW] Would run: git pull origin {branch}")
|
||||
print("[PREVIEW] Would run: git fetch origin --prune --tags --force")
|
||||
print("[PREVIEW] Would run: git pull --ff-only")
|
||||
return
|
||||
|
||||
run_git_command("git fetch origin")
|
||||
run_git_command(f"git pull origin {branch}")
|
||||
print("[INFO] Syncing with remote before making any changes...")
|
||||
run_git_command("git fetch origin --prune --tags --force")
|
||||
run_git_command("git pull --ff-only")
|
||||
|
||||
def is_highest_version_tag(tag: str) -> bool:
|
||||
"""
|
||||
Return True if `tag` is the highest version among all tags matching v*.
|
||||
Comparison uses `sort -V` for natural version ordering.
|
||||
"""
|
||||
all_v = _capture("git tag --list 'v*'")
|
||||
if not all_v:
|
||||
return True # No tags yet, so the current tag is the highest
|
||||
|
||||
# Get the latest tag in natural version order
|
||||
latest = _capture("git tag --list 'v*' | sort -V | tail -n1")
|
||||
print(f"[INFO] Latest tag: {latest}, Current tag: {tag}")
|
||||
|
||||
# Ensure that the current tag is always considered the highest if it's the latest one
|
||||
return tag >= latest # Use comparison operator to consider all future tags
|
||||
|
||||
|
||||
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
|
||||
"""
|
||||
Move the floating 'latest' tag to the newly created release tag.
|
||||
|
||||
Implementation details:
|
||||
- We explicitly dereference the tag object via `<tag>^{}` so that
|
||||
'latest' always points at the underlying commit, not at another tag.
|
||||
- We create/update 'latest' as an annotated tag with a short message so
|
||||
Git configurations that enforce annotated/signed tags do not fail
|
||||
with "no tag message".
|
||||
Notes:
|
||||
- We dereference the tag object via `<tag>^{}` so that 'latest' points to the commit.
|
||||
- 'latest' is forced (floating tag), therefore the push uses --force.
|
||||
"""
|
||||
target_ref = f"{new_tag}^{{}}"
|
||||
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} "
|
||||
f'-m "Floating latest tag for {new_tag}"')
|
||||
print(
|
||||
f'[PREVIEW] Would run: git tag -f -a latest {target_ref} '
|
||||
f'-m "Floating latest tag for {new_tag}"'
|
||||
)
|
||||
print("[PREVIEW] Would run: git push origin latest --force")
|
||||
return
|
||||
|
||||
run_git_command(
|
||||
f'git tag -f -a latest {target_ref} '
|
||||
f'-m "Floating latest tag for {new_tag}"'
|
||||
f'git tag -f -a latest {target_ref} -m "Floating latest tag for {new_tag}"'
|
||||
)
|
||||
run_git_command("git push origin latest --force")
|
||||
|
||||
29
src/pkgmgr/actions/release/prompts.py
Normal file
29
src/pkgmgr/actions/release/prompts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def should_delete_branch(force: bool) -> bool:
|
||||
"""
|
||||
Ask whether the current branch should be deleted after a successful release.
|
||||
|
||||
- If force=True: skip prompt and return True.
|
||||
- If non-interactive stdin: do NOT delete by default.
|
||||
"""
|
||||
if force:
|
||||
return True
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
answer = input("Delete the current branch after release? [y/N] ").strip().lower()
|
||||
return answer in ("y", "yes")
|
||||
|
||||
|
||||
def confirm_proceed_release() -> bool:
|
||||
"""
|
||||
Ask whether to proceed with the REAL release after the preview phase.
|
||||
"""
|
||||
try:
|
||||
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return answer in ("y", "yes")
|
||||
231
src/pkgmgr/actions/release/workflow.py
Normal file
231
src/pkgmgr/actions/release/workflow.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.git import get_current_branch, GitError
|
||||
|
||||
from .files import (
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_flake_version,
|
||||
update_pkgbuild_version,
|
||||
update_pyproject_version,
|
||||
update_spec_changelog,
|
||||
update_spec_version,
|
||||
)
|
||||
from .git_ops import (
|
||||
ensure_clean_and_synced,
|
||||
is_highest_version_tag,
|
||||
run_git_command,
|
||||
update_latest_tag,
|
||||
)
|
||||
from .prompts import confirm_proceed_release, should_delete_branch
|
||||
from .versioning import bump_semver, determine_current_version
|
||||
|
||||
|
||||
def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Determine current branch early
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitError:
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
# Pull BEFORE making any modifications
|
||||
ensure_clean_and_synced(preview=preview)
|
||||
|
||||
current_ver = determine_current_version()
|
||||
new_ver = bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
|
||||
mode = "PREVIEW" if preview else "REAL"
|
||||
print(f"Release mode: {mode}")
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
|
||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||
changelog_message = update_changelog(
|
||||
changelog_path,
|
||||
new_ver_str,
|
||||
message=message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
flake_path = os.path.join(repo_root, "flake.nix")
|
||||
update_flake_version(flake_path, new_ver_str, preview=preview)
|
||||
|
||||
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
||||
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
||||
|
||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
|
||||
update_debian_changelog(
|
||||
debian_changelog_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
update_spec_changelog(
|
||||
spec_path=spec_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
files_to_add = [
|
||||
pyproject_path,
|
||||
changelog_path,
|
||||
flake_path,
|
||||
pkgbuild_path,
|
||||
spec_path,
|
||||
debian_changelog_path,
|
||||
]
|
||||
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
||||
|
||||
if preview:
|
||||
for path in existing_files:
|
||||
print(f"[PREVIEW] Would run: git add {path}")
|
||||
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||
print(f"[PREVIEW] Would run: git push origin {new_tag}")
|
||||
|
||||
if is_highest_version_tag(new_tag):
|
||||
update_latest_tag(new_tag, preview=True)
|
||||
else:
|
||||
print(f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
||||
|
||||
if close and branch not in ("main", "master"):
|
||||
if force:
|
||||
print(f"[PREVIEW] Would delete branch {branch} (forced).")
|
||||
else:
|
||||
print(f"[PREVIEW] Would ask whether to delete branch {branch} after release.")
|
||||
return
|
||||
|
||||
for path in existing_files:
|
||||
run_git_command(f"git add {path}")
|
||||
|
||||
run_git_command(f'git commit -am "{commit_msg}"')
|
||||
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
||||
|
||||
# Push branch and ONLY the newly created version tag (no --tags)
|
||||
run_git_command(f"git push origin {branch}")
|
||||
run_git_command(f"git push origin {new_tag}")
|
||||
|
||||
# Update 'latest' only if this is the highest version tag
|
||||
try:
|
||||
if is_highest_version_tag(new_tag):
|
||||
update_latest_tag(new_tag, preview=False)
|
||||
else:
|
||||
print(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
||||
except GitError as exc:
|
||||
print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}")
|
||||
print("'latest' tag was not updated.")
|
||||
|
||||
print(f"Release {new_ver_str} completed.")
|
||||
|
||||
if close:
|
||||
if branch in ("main", "master"):
|
||||
print(f"[INFO] close=True but current branch is {branch}; skipping branch deletion.")
|
||||
return
|
||||
|
||||
if not should_delete_branch(force=force):
|
||||
print(f"[INFO] Branch deletion declined. Keeping branch {branch}.")
|
||||
return
|
||||
|
||||
print(f"[INFO] Deleting branch {branch} after successful release...")
|
||||
try:
|
||||
close_branch(name=branch, base_branch="main", cwd=".")
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||
|
||||
|
||||
def release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
force=force,
|
||||
)
|
||||
return
|
||||
|
||||
# If force or non-interactive: no preview+confirmation step
|
||||
if force or (not sys.stdin.isatty()):
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
force=force,
|
||||
)
|
||||
return
|
||||
|
||||
print("[INFO] Running preview before actual release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
force=force,
|
||||
)
|
||||
|
||||
if not confirm_proceed_release():
|
||||
print()
|
||||
return
|
||||
|
||||
print("\n[INFO] Running REAL release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
force=force,
|
||||
)
|
||||
@@ -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://s.veen.world/pkgmgr\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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -36,7 +36,7 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
||||
return {"repositories": []}
|
||||
|
||||
|
||||
def _find_defaults_source_dir() -> str | None:
|
||||
def _find_defaults_source_dir() -> Optional[str]:
|
||||
"""
|
||||
Find the directory inside the installed pkgmgr package OR the
|
||||
project root that contains default config files.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
# pkgmgr/run_command.py
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -40,7 +40,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -83,7 +83,7 @@ def _repo_key(repo: Repo) -> Tuple[str, str, str]:
|
||||
def _merge_repo_lists(
|
||||
base_list: List[Repo],
|
||||
new_list: List[Repo],
|
||||
category_name: str | None = None,
|
||||
category_name: Optional[str] = None,
|
||||
) -> List[Repo]:
|
||||
"""
|
||||
Merge two repository lists, matching by (provider, account, repository).
|
||||
@@ -143,7 +143,7 @@ def _load_yaml_file(path: Path) -> Dict[str, Any]:
|
||||
|
||||
def _load_layer_dir(
|
||||
config_dir: Path,
|
||||
skip_filename: str | None = None,
|
||||
skip_filename: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load all *.yml/*.yaml from a directory as layered defaults.
|
||||
|
||||
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()
|
||||
@@ -7,11 +7,13 @@ This test is intended to be run inside the Docker container where:
|
||||
- the config/config.yaml is present,
|
||||
- and it is safe to perform real git operations.
|
||||
|
||||
It passes if the command completes without raising an exception.
|
||||
It passes if BOTH commands complete successfully (in separate tests):
|
||||
1) pkgmgr update --all --clone-mode https --no-verification
|
||||
2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification
|
||||
"""
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from test_install_pkgmgr_shallow import (
|
||||
@@ -22,55 +24,35 @@ from test_install_pkgmgr_shallow import (
|
||||
|
||||
|
||||
class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
||||
def _run_pkgmgr_update_all_https(self) -> None:
|
||||
def _run_cmd(self, cmd: list[str], label: str) -> None:
|
||||
"""
|
||||
Helper that runs the CLI command via main.py and provides
|
||||
extra diagnostics if the command exits with a non-zero code.
|
||||
Run a real CLI command and raise a helpful assertion on failure.
|
||||
"""
|
||||
cmd_repr = "pkgmgr update --all --clone-mode https --no-verification"
|
||||
original_argv = sys.argv
|
||||
cmd_repr = " ".join(cmd)
|
||||
env = os.environ.copy()
|
||||
|
||||
try:
|
||||
sys.argv = [
|
||||
"pkgmgr",
|
||||
"update",
|
||||
"--all",
|
||||
"--clone-mode",
|
||||
"https",
|
||||
"--no-verification",
|
||||
]
|
||||
print(f"\n[TEST] Running ({label}): {cmd_repr}")
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
cwd=os.getcwd(),
|
||||
env=env,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"\n[TEST] Command failed ({label})")
|
||||
print(f"[TEST] Command : {cmd_repr}")
|
||||
print(f"[TEST] Exit code: {exc.returncode}")
|
||||
|
||||
try:
|
||||
# Execute main.py as if it was called from CLI.
|
||||
# This will run the full update pipeline inside the container.
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
# Convert SystemExit into a more helpful assertion with debug output.
|
||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
nix_profile_list_debug(f"ON FAILURE ({label})")
|
||||
|
||||
print("\n[TEST] pkgmgr update --all failed with SystemExit")
|
||||
print(f"[TEST] Command : {cmd_repr}")
|
||||
print(f"[TEST] Exit code: {exit_code}")
|
||||
raise AssertionError(
|
||||
f"({label}) {cmd_repr!r} failed with exit code {exc.returncode}. "
|
||||
"Scroll up to see the full pkgmgr/nix output inside the container."
|
||||
) from exc
|
||||
|
||||
# Additional Nix profile debug on failure (useful if any update
|
||||
# step interacts with Nix-based tooling).
|
||||
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
|
||||
|
||||
raise AssertionError(
|
||||
f"{cmd_repr!r} failed with exit code {exit_code}. "
|
||||
"Scroll up to see the full pkgmgr/make output inside the container."
|
||||
) from exc
|
||||
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
def test_update_all_repositories_https(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr update --all --clone-mode https --no-verification
|
||||
|
||||
This will perform real git update operations inside the container.
|
||||
The test succeeds if no exception is raised and `pkgmgr --help`
|
||||
works in a fresh interactive bash session afterwards.
|
||||
"""
|
||||
def _common_setup(self) -> None:
|
||||
# Debug before cleanup
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
|
||||
@@ -81,11 +63,28 @@ class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
|
||||
# Run the actual update with extended diagnostics
|
||||
self._run_pkgmgr_update_all_https()
|
||||
def test_update_all_repositories_https_pkgmgr(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr update --all --clone-mode https --no-verification
|
||||
"""
|
||||
self._common_setup()
|
||||
|
||||
# After successful update: show `pkgmgr --help`
|
||||
# via interactive bash (same helper as in the other integration tests).
|
||||
args = ["update", "--all", "--clone-mode", "https", "--no-verification"]
|
||||
self._run_cmd(["pkgmgr", *args], label="pkgmgr")
|
||||
|
||||
# After successful update: show `pkgmgr --help` via interactive bash
|
||||
pkgmgr_help_debug()
|
||||
|
||||
def test_update_all_repositories_https_nix_pkgmgr(self) -> None:
|
||||
"""
|
||||
Run: nix run .#pkgmgr -- update --all --clone-mode https --no-verification
|
||||
"""
|
||||
self._common_setup()
|
||||
|
||||
args = ["update", "--all", "--clone-mode", "https", "--no-verification"]
|
||||
self._run_cmd(["nix", "run", ".#pkgmgr", "--", *args], label="nix run .#pkgmgr")
|
||||
|
||||
# After successful update: show `pkgmgr --help` via interactive bash
|
||||
pkgmgr_help_debug()
|
||||
|
||||
|
||||
|
||||
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
@@ -5,8 +5,9 @@ from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.actions.release.git_ops import (
|
||||
ensure_clean_and_synced,
|
||||
is_highest_version_tag,
|
||||
run_git_command,
|
||||
sync_branch_with_remote,
|
||||
update_latest_tag,
|
||||
)
|
||||
|
||||
@@ -14,12 +15,13 @@ from pkgmgr.actions.release.git_ops import (
|
||||
class TestRunGitCommand(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_run_git_command_success(self, mock_run) -> None:
|
||||
# No exception means success
|
||||
run_git_command("git status")
|
||||
mock_run.assert_called_once()
|
||||
args, kwargs = mock_run.call_args
|
||||
self.assertIn("git status", args[0])
|
||||
self.assertTrue(kwargs.get("check"))
|
||||
self.assertTrue(kwargs.get("capture_output"))
|
||||
self.assertTrue(kwargs.get("text"))
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_run_git_command_failure_raises_git_error(self, mock_run) -> None:
|
||||
@@ -36,58 +38,161 @@ class TestRunGitCommand(unittest.TestCase):
|
||||
run_git_command("git status")
|
||||
|
||||
|
||||
class TestSyncBranchWithRemote(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||
def test_sync_branch_with_remote_skips_non_main_master(
|
||||
self,
|
||||
mock_run_git_command,
|
||||
) -> None:
|
||||
sync_branch_with_remote("feature/my-branch", preview=False)
|
||||
mock_run_git_command.assert_not_called()
|
||||
class TestEnsureCleanAndSynced(unittest.TestCase):
|
||||
def _fake_run(self, cmd: str, *args, **kwargs):
|
||||
class R:
|
||||
def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0):
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||
def test_sync_branch_with_remote_preview_on_main_does_not_run_git(
|
||||
self,
|
||||
mock_run_git_command,
|
||||
) -> None:
|
||||
sync_branch_with_remote("main", preview=True)
|
||||
mock_run_git_command.assert_not_called()
|
||||
# upstream detection
|
||||
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||
return R(stdout="origin/main")
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||
def test_sync_branch_with_remote_main_runs_fetch_and_pull(
|
||||
self,
|
||||
mock_run_git_command,
|
||||
) -> None:
|
||||
sync_branch_with_remote("main", preview=False)
|
||||
# fetch/pull should be invoked in real mode
|
||||
if cmd == "git fetch --prune --tags":
|
||||
return R(stdout="")
|
||||
if cmd == "git pull --ff-only":
|
||||
return R(stdout="Already up to date.")
|
||||
|
||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
||||
self.assertIn("git fetch origin", calls)
|
||||
self.assertIn("git pull origin main", calls)
|
||||
return R(stdout="")
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_ensure_clean_and_synced_preview_does_not_run_git_commands(self, mock_run) -> None:
|
||||
def fake(cmd: str, *args, **kwargs):
|
||||
class R:
|
||||
def __init__(self, stdout: str = ""):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = 0
|
||||
|
||||
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||
return R(stdout="origin/main")
|
||||
return R(stdout="")
|
||||
|
||||
mock_run.side_effect = fake
|
||||
|
||||
ensure_clean_and_synced(preview=True)
|
||||
|
||||
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any("git rev-parse" in c for c in called_cmds))
|
||||
self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds))
|
||||
self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds))
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_ensure_clean_and_synced_no_upstream_skips(self, mock_run) -> None:
|
||||
def fake(cmd: str, *args, **kwargs):
|
||||
class R:
|
||||
def __init__(self, stdout: str = ""):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = 0
|
||||
|
||||
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||
return R(stdout="") # no upstream
|
||||
return R(stdout="")
|
||||
|
||||
mock_run.side_effect = fake
|
||||
|
||||
ensure_clean_and_synced(preview=False)
|
||||
|
||||
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any("git rev-parse" in c for c in called_cmds))
|
||||
self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds))
|
||||
self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds))
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_ensure_clean_and_synced_real_runs_fetch_and_pull(self, mock_run) -> None:
|
||||
mock_run.side_effect = self._fake_run
|
||||
|
||||
ensure_clean_and_synced(preview=False)
|
||||
|
||||
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertIn("git fetch origin --prune --tags --force", called_cmds)
|
||||
self.assertIn("git pull --ff-only", called_cmds)
|
||||
|
||||
|
||||
|
||||
class TestIsHighestVersionTag(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_is_highest_version_tag_no_tags_true(self, mock_run) -> None:
|
||||
def fake(cmd: str, *args, **kwargs):
|
||||
class R:
|
||||
def __init__(self, stdout: str = ""):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = 0
|
||||
|
||||
if "git tag --list" in cmd and "'v*'" in cmd:
|
||||
return R(stdout="") # no tags
|
||||
return R(stdout="")
|
||||
|
||||
mock_run.side_effect = fake
|
||||
|
||||
self.assertTrue(is_highest_version_tag("v1.0.0"))
|
||||
|
||||
# ensure at least the list command was queried
|
||||
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any("git tag --list" in c for c in called_cmds))
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||
def test_is_highest_version_tag_compares_sort_v(self, mock_run) -> None:
|
||||
"""
|
||||
This test is aligned with the CURRENT implementation:
|
||||
|
||||
return tag >= latest
|
||||
|
||||
which is a *string comparison*, not a semantic version compare.
|
||||
Therefore, a candidate like v1.2.0 is lexicographically >= v1.10.0
|
||||
(because '2' > '1' at the first differing char after 'v1.').
|
||||
"""
|
||||
def fake(cmd: str, *args, **kwargs):
|
||||
class R:
|
||||
def __init__(self, stdout: str = ""):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = 0
|
||||
|
||||
if cmd.strip() == "git tag --list 'v*'":
|
||||
return R(stdout="v1.0.0\nv1.2.0\nv1.10.0\n")
|
||||
if "git tag --list 'v*'" in cmd and "sort -V" in cmd and "tail -n1" in cmd:
|
||||
return R(stdout="v1.10.0")
|
||||
return R(stdout="")
|
||||
|
||||
mock_run.side_effect = fake
|
||||
|
||||
# With the current implementation (string >=), both of these are True.
|
||||
self.assertTrue(is_highest_version_tag("v1.10.0"))
|
||||
self.assertTrue(is_highest_version_tag("v1.2.0"))
|
||||
|
||||
# And a clearly lexicographically smaller candidate should be False.
|
||||
# Example: "v1.0.0" < "v1.10.0"
|
||||
self.assertFalse(is_highest_version_tag("v1.0.0"))
|
||||
|
||||
# Ensure both capture commands were executed
|
||||
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any(cmd == "git tag --list 'v*'" for cmd in called_cmds))
|
||||
self.assertTrue(any("sort -V" in cmd and "tail -n1" in cmd for cmd in called_cmds))
|
||||
|
||||
|
||||
class TestUpdateLatestTag(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||
def test_update_latest_tag_preview_does_not_call_git(
|
||||
self,
|
||||
mock_run_git_command,
|
||||
) -> None:
|
||||
def test_update_latest_tag_preview_does_not_call_git(self, mock_run_git_command) -> None:
|
||||
update_latest_tag("v1.2.3", preview=True)
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||
def test_update_latest_tag_real_calls_git_with_dereference_and_message(
|
||||
self,
|
||||
mock_run_git_command,
|
||||
) -> None:
|
||||
def test_update_latest_tag_real_calls_git(self, mock_run_git_command) -> None:
|
||||
update_latest_tag("v1.2.3", preview=False)
|
||||
|
||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
||||
# Must dereference the tag object and create an annotated tag with message
|
||||
self.assertIn(
|
||||
'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for v1.2.3"',
|
||||
calls,
|
||||
)
|
||||
self.assertIn("git push origin latest --force", calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
14
tests/unit/pkgmgr/actions/release/test_init.py
Normal file
14
tests/unit/pkgmgr/actions/release/test_init.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestReleasePackageInit(unittest.TestCase):
|
||||
def test_release_is_reexported(self) -> None:
|
||||
from pkgmgr.actions.release import release # noqa: F401
|
||||
|
||||
self.assertTrue(callable(release))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.prompts import (
|
||||
confirm_proceed_release,
|
||||
should_delete_branch,
|
||||
)
|
||||
|
||||
|
||||
class TestShouldDeleteBranch(unittest.TestCase):
|
||||
def test_force_true_skips_prompt_and_returns_true(self) -> None:
|
||||
self.assertTrue(should_delete_branch(force=True))
|
||||
|
||||
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=False)
|
||||
def test_non_interactive_returns_false(self, _mock_isatty) -> None:
|
||||
self.assertFalse(should_delete_branch(force=False))
|
||||
|
||||
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True)
|
||||
@patch("builtins.input", return_value="y")
|
||||
def test_interactive_yes_returns_true(self, _mock_input, _mock_isatty) -> None:
|
||||
self.assertTrue(should_delete_branch(force=False))
|
||||
|
||||
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True)
|
||||
@patch("builtins.input", return_value="N")
|
||||
def test_interactive_no_returns_false(self, _mock_input, _mock_isatty) -> None:
|
||||
self.assertFalse(should_delete_branch(force=False))
|
||||
|
||||
|
||||
class TestConfirmProceedRelease(unittest.TestCase):
|
||||
@patch("builtins.input", return_value="y")
|
||||
def test_confirm_yes(self, _mock_input) -> None:
|
||||
self.assertTrue(confirm_proceed_release())
|
||||
|
||||
@patch("builtins.input", return_value="no")
|
||||
def test_confirm_no(self, _mock_input) -> None:
|
||||
self.assertFalse(confirm_proceed_release())
|
||||
|
||||
@patch("builtins.input", side_effect=EOFError)
|
||||
def test_confirm_eof_returns_false(self, _mock_input) -> None:
|
||||
self.assertFalse(confirm_proceed_release())
|
||||
|
||||
@patch("builtins.input", side_effect=KeyboardInterrupt)
|
||||
def test_confirm_keyboard_interrupt_returns_false(self, _mock_input) -> None:
|
||||
self.assertFalse(confirm_proceed_release())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,155 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
from pkgmgr.actions.release import release
|
||||
|
||||
|
||||
class TestReleaseOrchestration(unittest.TestCase):
|
||||
def test_release_happy_path_uses_helpers_and_git(self) -> None:
|
||||
with patch("pkgmgr.actions.release.sys.stdin.isatty", return_value=False), \
|
||||
patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
|
||||
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
|
||||
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
|
||||
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
|
||||
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
|
||||
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
|
||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
||||
mock_determine_current_version.return_value = SemVer(1, 2, 3)
|
||||
mock_bump_semver.return_value = SemVer(1, 2, 4)
|
||||
|
||||
release(
|
||||
pyproject_path="pyproject.toml",
|
||||
changelog_path="CHANGELOG.md",
|
||||
release_type="patch",
|
||||
message="Test release",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
# Current version + bump
|
||||
mock_determine_current_version.assert_called_once()
|
||||
mock_bump_semver.assert_called_once()
|
||||
args, kwargs = mock_bump_semver.call_args
|
||||
self.assertEqual(args[0], SemVer(1, 2, 3))
|
||||
self.assertEqual(args[1], "patch")
|
||||
self.assertEqual(kwargs, {})
|
||||
|
||||
# pyproject update
|
||||
mock_update_pyproject.assert_called_once()
|
||||
args, kwargs = mock_update_pyproject.call_args
|
||||
self.assertEqual(args[0], "pyproject.toml")
|
||||
self.assertEqual(args[1], "1.2.4")
|
||||
self.assertEqual(kwargs.get("preview"), False)
|
||||
|
||||
# changelog update (Projekt)
|
||||
mock_update_changelog.assert_called_once()
|
||||
args, kwargs = mock_update_changelog.call_args
|
||||
self.assertEqual(args[0], "CHANGELOG.md")
|
||||
self.assertEqual(args[1], "1.2.4")
|
||||
self.assertEqual(kwargs.get("message"), "Test release")
|
||||
self.assertEqual(kwargs.get("preview"), False)
|
||||
|
||||
# Additional packaging helpers called with preview=False
|
||||
mock_update_flake.assert_called_once()
|
||||
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
|
||||
|
||||
mock_update_pkgbuild.assert_called_once()
|
||||
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
|
||||
|
||||
mock_update_spec.assert_called_once()
|
||||
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
|
||||
|
||||
mock_update_debian_changelog.assert_called_once()
|
||||
self.assertEqual(
|
||||
mock_update_debian_changelog.call_args[1].get("preview"),
|
||||
False,
|
||||
)
|
||||
|
||||
# Fedora / RPM %changelog helper
|
||||
mock_update_spec_changelog.assert_called_once()
|
||||
self.assertEqual(
|
||||
mock_update_spec_changelog.call_args[1].get("preview"),
|
||||
False,
|
||||
)
|
||||
|
||||
# Git operations
|
||||
mock_get_current_branch.assert_called_once()
|
||||
self.assertEqual(mock_get_current_branch.return_value, "develop")
|
||||
|
||||
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
||||
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
|
||||
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
|
||||
self.assertIn("git push origin develop", git_calls)
|
||||
self.assertIn("git push origin --tags", git_calls)
|
||||
|
||||
# Branch sync & latest tag update
|
||||
mock_sync_branch.assert_called_once_with("develop", preview=False)
|
||||
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=False)
|
||||
|
||||
def test_release_preview_mode_skips_git_and_uses_preview_flag(self) -> None:
|
||||
with patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
|
||||
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
|
||||
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
|
||||
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
|
||||
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
|
||||
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
|
||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
||||
mock_determine_current_version.return_value = SemVer(1, 2, 3)
|
||||
mock_bump_semver.return_value = SemVer(1, 2, 4)
|
||||
|
||||
release(
|
||||
pyproject_path="pyproject.toml",
|
||||
changelog_path="CHANGELOG.md",
|
||||
release_type="patch",
|
||||
message="Preview release",
|
||||
preview=True,
|
||||
)
|
||||
|
||||
# All update helpers must be called with preview=True
|
||||
mock_update_pyproject.assert_called_once()
|
||||
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
|
||||
|
||||
mock_update_changelog.assert_called_once()
|
||||
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
|
||||
|
||||
mock_update_flake.assert_called_once()
|
||||
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
|
||||
|
||||
mock_update_pkgbuild.assert_called_once()
|
||||
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
|
||||
|
||||
mock_update_spec.assert_called_once()
|
||||
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
|
||||
|
||||
mock_update_debian_changelog.assert_called_once()
|
||||
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
|
||||
|
||||
# Fedora / RPM spec changelog helper in preview mode
|
||||
mock_update_spec_changelog.assert_called_once()
|
||||
self.assertTrue(mock_update_spec_changelog.call_args[1].get("preview"))
|
||||
|
||||
# In preview mode no real git commands must be executed
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
# Branch sync is still invoked (with preview=True internally),
|
||||
# and latest tag is only announced in preview mode
|
||||
mock_sync_branch.assert_called_once_with("develop", preview=True)
|
||||
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.workflow import release
|
||||
|
||||
|
||||
class TestWorkflowReleaseEntryPoint(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_release_preview_calls_impl_preview_only(self, mock_impl) -> None:
|
||||
release(preview=True, force=False, close=False)
|
||||
|
||||
mock_impl.assert_called_once()
|
||||
kwargs = mock_impl.call_args.kwargs
|
||||
self.assertTrue(kwargs["preview"])
|
||||
self.assertFalse(kwargs["force"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=False)
|
||||
def test_release_non_interactive_runs_real_without_confirmation(self, _mock_isatty, mock_impl) -> None:
|
||||
release(preview=False, force=False, close=False)
|
||||
|
||||
mock_impl.assert_called_once()
|
||||
kwargs = mock_impl.call_args.kwargs
|
||||
self.assertFalse(kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_release_force_runs_real_without_confirmation(self, mock_impl) -> None:
|
||||
release(preview=False, force=True, close=False)
|
||||
|
||||
mock_impl.assert_called_once()
|
||||
kwargs = mock_impl.call_args.kwargs
|
||||
self.assertFalse(kwargs["preview"])
|
||||
self.assertTrue(kwargs["force"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
@patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=False)
|
||||
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True)
|
||||
def test_release_interactive_decline_runs_only_preview(self, _mock_isatty, _mock_confirm, mock_impl) -> None:
|
||||
release(preview=False, force=False, close=False)
|
||||
|
||||
# interactive path: preview first, then decline => only one call
|
||||
self.assertEqual(mock_impl.call_count, 1)
|
||||
self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
@patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=True)
|
||||
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True)
|
||||
def test_release_interactive_accept_runs_preview_then_real(self, _mock_isatty, _mock_confirm, mock_impl) -> None:
|
||||
release(preview=False, force=False, close=False)
|
||||
|
||||
self.assertEqual(mock_impl.call_count, 2)
|
||||
self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"])
|
||||
self.assertFalse(mock_impl.call_args_list[1].kwargs["preview"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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