Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -13,8 +13,11 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
uses: ./.github/workflows/test-integration.yml
|
uses: ./.github/workflows/test-integration.yml
|
||||||
|
|
||||||
test-container:
|
test-env-virtual:
|
||||||
uses: ./.github/workflows/test-container.yml
|
uses: ./.github/workflows/test-env-virtual.yml
|
||||||
|
|
||||||
|
test-env-nix:
|
||||||
|
uses: ./.github/workflows/test-env-nix.yml
|
||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
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:
|
test-integration:
|
||||||
uses: ./.github/workflows/test-integration.yml
|
uses: ./.github/workflows/test-integration.yml
|
||||||
|
|
||||||
test-container:
|
test-env-virtual:
|
||||||
uses: ./.github/workflows/test-container.yml
|
uses: ./.github/workflows/test-env-virtual.yml
|
||||||
|
|
||||||
|
test-env-nix:
|
||||||
|
uses: ./.github/workflows/test-env-nix.yml
|
||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
uses: ./.github/workflows/test-e2e.yml
|
||||||
@@ -30,7 +33,8 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- test-unit
|
- test-unit
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-container
|
- test-env-nix
|
||||||
|
- test-env-virtual
|
||||||
- test-e2e
|
- test-e2e
|
||||||
- test-virgin-user
|
- test-virgin-user
|
||||||
- test-virgin-root
|
- test-virgin-root
|
||||||
|
|||||||
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:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-container:
|
test-env-virtual:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
@@ -25,4 +25,4 @@ jobs:
|
|||||||
- name: Run container tests (${{ matrix.distro }})
|
- name: Run container tests (${{ matrix.distro }})
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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:
|
test-virgin-root:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -15,44 +19,38 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
- name: Virgin Arch pkgmgr flake test (root)
|
# 🔹 BUILD virgin image if missing
|
||||||
|
- name: Build virgin container (${{ matrix.distro }})
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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 \
|
docker run --rm \
|
||||||
-v "$PWD":/src \
|
-v "$PWD":/src \
|
||||||
-v pkgmgr_repos:/root/Repositories \
|
-v pkgmgr_repos:/root/Repositories \
|
||||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||||
-w /src \
|
-w /src \
|
||||||
archlinux:latest \
|
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo ">>> Updating and upgrading Arch system..."
|
git config --global --add safe.directory /src
|
||||||
pacman -Syu --noconfirm git python python-pip nix >/dev/null
|
|
||||||
|
|
||||||
echo ">>> Creating isolated virtual environment for pkgmgr..."
|
make install
|
||||||
python -m venv /tmp/pkgmgr-venv
|
make setup
|
||||||
|
|
||||||
echo ">>> Activating virtual environment..."
|
. "$HOME/.venvs/pkgmgr/bin/activate"
|
||||||
source /tmp/pkgmgr-venv/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"
|
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
|
pkgmgr update pkgmgr --clone-mode shallow --no-verification
|
||||||
|
|
||||||
echo ">>> Running: pkgmgr version pkgmgr"
|
|
||||||
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:
|
test-virgin-user:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -15,59 +19,47 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo ">>> Starting virgin ArchLinux container test (non-root user with sudo)..."
|
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$PWD":/src \
|
-v "$PWD":/src \
|
||||||
archlinux:latest \
|
-w /src \
|
||||||
|
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo ">>> [root] Updating and upgrading Arch system..."
|
make install
|
||||||
pacman -Syu --noconfirm git python python-pip sudo base-devel debugedit
|
|
||||||
|
|
||||||
echo ">>> [root] Creating non-root user dev..."
|
|
||||||
useradd -m dev
|
useradd -m dev
|
||||||
|
|
||||||
echo ">>> [root] Allowing passwordless sudo for dev..."
|
|
||||||
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
||||||
chmod 0440 /etc/sudoers.d/dev
|
chmod 0440 /etc/sudoers.d/dev
|
||||||
|
|
||||||
echo ">>> [root] Adjusting ownership of /src for dev..."
|
|
||||||
chown -R dev:dev /src
|
chown -R dev:dev /src
|
||||||
|
|
||||||
echo ">>> [root] Running pkgmgr flow as non-root user dev..."
|
mkdir -p /nix/store /nix/var/nix /nix/var/log/nix /nix/var/nix/profiles
|
||||||
sudo -u dev env PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
|
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
|
set -euo pipefail
|
||||||
cd /src
|
cd /src
|
||||||
|
|
||||||
echo \">>> [dev] Using user: \$(whoami)\"
|
make setup-venv
|
||||||
echo \">>> [dev] Running scripts/installation/main.sh...\"
|
|
||||||
bash scripts/installation/main.sh
|
|
||||||
|
|
||||||
echo \">>> [dev] Activating venv...\"
|
|
||||||
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
|
. \"\$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
|
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
|
||||||
|
"
|
||||||
'
|
'
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ Thumbs.db
|
|||||||
# Nix Cache to speed up tests
|
# Nix Cache to speed up tests
|
||||||
.nix/
|
.nix/
|
||||||
.nix-dev-installed
|
.nix-dev-installed
|
||||||
|
flake.lock
|
||||||
|
|
||||||
# Ignore logs
|
# Ignore logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,3 +1,49 @@
|
|||||||
|
## [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] - 2025-12-11
|
||||||
|
|
||||||
* **1.0.0 – Official Stable Release 🎉**
|
* **1.0.0 – Official Stable Release 🎉**
|
||||||
|
|||||||
81
Dockerfile
81
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
|
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"]
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
|
||||||
# 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"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Unprivileged user for Arch package build (makepkg)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
RUN useradd -m aur_builder || true
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Copy scripts and install distro dependencies
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Copy only scripts first so dependency installation can run early
|
# Copy scripts first so dependency installation can be cached
|
||||||
COPY scripts/ scripts/
|
COPY scripts/installation/ scripts/installation/
|
||||||
RUN find scripts -type f -name '*.sh' -exec chmod +x {} \;
|
|
||||||
|
|
||||||
# Install distro-specific build dependencies (and AUR builder on Arch)
|
# Install distro-specific build dependencies (including make)
|
||||||
RUN scripts/installation/run-dependencies.sh
|
RUN bash scripts/installation/dependencies.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# Virgin default
|
||||||
# Select distro-specific Docker entrypoint
|
CMD ["bash"]
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 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 . .
|
COPY . .
|
||||||
RUN find scripts -type f -name '*.sh' -exec chmod +x {} \;
|
|
||||||
|
|
||||||
RUN set -e; \
|
# Build and install distro-native package-manager package
|
||||||
|
RUN set -euo pipefail; \
|
||||||
echo "Building and installing package-manager via make install..."; \
|
echo "Building and installing package-manager via make install..."; \
|
||||||
make install; \
|
make install; \
|
||||||
rm -rf /build
|
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
|
WORKDIR /src
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||||
CMD ["pkgmgr", "--help"]
|
CMD ["pkgmgr", "--help"]
|
||||||
|
|||||||
72
Makefile
72
Makefile
@@ -1,9 +1,12 @@
|
|||||||
.PHONY: install setup uninstall \
|
.PHONY: install uninstall \
|
||||||
test build build-no-cache test-unit test-e2e test-integration \
|
build build-no-cache build-no-cache-all build-missing \
|
||||||
test-container
|
delete-volumes purge \
|
||||||
|
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
||||||
|
setup setup-venv setup-nix
|
||||||
|
|
||||||
# Distro
|
# Distro
|
||||||
# Options: arch debian ubuntu fedora centos
|
# Options: arch debian ubuntu fedora centos
|
||||||
|
DISTROS ?= arch debian ubuntu fedora centos
|
||||||
distro ?= arch
|
distro ?= arch
|
||||||
export distro
|
export distro
|
||||||
|
|
||||||
@@ -29,19 +32,50 @@ TEST_PATTERN := test_*.py
|
|||||||
export TEST_PATTERN
|
export TEST_PATTERN
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# PKGMGR setup (developer wrapper -> scripts/installation/main.sh)
|
# System install
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
setup:
|
install:
|
||||||
|
@echo "Building and installing distro-native package-manager for this system..."
|
||||||
@bash scripts/installation/main.sh
|
@bash scripts/installation/main.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)
|
# Docker build targets (delegated to scripts/build)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
build-no-cache:
|
|
||||||
@bash scripts/build/build-image-no-cache.sh
|
|
||||||
|
|
||||||
build:
|
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)
|
# Test targets (delegated to scripts/test)
|
||||||
@@ -56,30 +90,20 @@ test-integration: build-missing
|
|||||||
test-e2e: build-missing
|
test-e2e: build-missing
|
||||||
@bash scripts/test/test-e2e.sh
|
@bash scripts/test/test-e2e.sh
|
||||||
|
|
||||||
test-container: build-missing
|
test-env-virtual: build-missing
|
||||||
@bash scripts/test/test-container.sh
|
@bash scripts/test/test-env-virtual.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
test-env-nix: build-missing
|
||||||
# Build only missing container images
|
@bash scripts/test/test-env-nix.sh
|
||||||
# ------------------------------------------------------------
|
|
||||||
build-missing:
|
|
||||||
@bash scripts/build/build-image-missing.sh
|
|
||||||
|
|
||||||
# Combined test target for local + CI (unit + integration + e2e)
|
# 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:
|
delete-volumes:
|
||||||
@docker volume rm pkgmgr_nix_store_${distro} pkgmgr_nix_cache_${distro} || true
|
@docker volume rm pkgmgr_nix_store_${distro} pkgmgr_nix_cache_${distro} || true
|
||||||
|
|
||||||
purge: delete-volumes build-no-cache
|
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
|
# Uninstall target
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Package Manager 🤖📦
|
# Package Manager 🤖📦
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||||
@@ -96,7 +98,7 @@ The following diagram gives a full overview of:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
**Diagram status:** 11 December 2025
|
**Diagram status:** 12 December 2025
|
||||||
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.0.0";
|
version = "1.3.0";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
post_install() {
|
post_install() {
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
}
|
}
|
||||||
|
|
||||||
post_upgrade() {
|
post_upgrade() {
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
}
|
}
|
||||||
|
|
||||||
post_remove() {
|
post_remove() {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ set -e
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
|
||||||
else
|
|
||||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,7 @@ rm -rf \
|
|||||||
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
||||||
|
|
||||||
%post
|
%post
|
||||||
# Initialize Nix (if needed) after installing the package-manager files.
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
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
|
|
||||||
|
|
||||||
%postun
|
%postun
|
||||||
echo ">>> package-manager removed. Nix itself was not removed."
|
echo ">>> package-manager removed. Nix itself was not removed."
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "package-manager"
|
name = "package-manager"
|
||||||
version = "1.0.0"
|
version = "1.3.0"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.9"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -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" \
|
|
||||||
.
|
|
||||||
120
scripts/build/image.sh
Executable file
120
scripts/build/image.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Unified docker image builder for all distros.
|
||||||
|
#
|
||||||
|
# Supports:
|
||||||
|
# --missing Build only if image does not exist
|
||||||
|
# --no-cache Disable docker layer cache
|
||||||
|
# --target Dockerfile target (e.g. virgin|full)
|
||||||
|
# --tag Override image tag (default: pkgmgr-$distro[-$target])
|
||||||
|
#
|
||||||
|
# Requires:
|
||||||
|
# - env var: distro (arch|debian|ubuntu|fedora|centos)
|
||||||
|
# - base.sh in same dir
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# distro=arch bash scripts/build/image.sh
|
||||||
|
# distro=arch bash scripts/build/image.sh --no-cache
|
||||||
|
# distro=arch bash scripts/build/image.sh --missing
|
||||||
|
# distro=arch bash scripts/build/image.sh --target virgin
|
||||||
|
# distro=arch bash scripts/build/image.sh --target virgin --missing
|
||||||
|
# distro=arch bash scripts/build/image.sh --tag myimg:arch
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
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="" # derive later unless --tag is provided
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
local default_tag="pkgmgr-${distro}"
|
||||||
|
if [[ -n "${TARGET:-}" ]]; then
|
||||||
|
default_tag="${default_tag}-${TARGET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
Usage: distro=<distro> $0 [--missing] [--no-cache] [--target <name>] [--tag <image>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--missing Build only if the image does not already exist
|
||||||
|
--no-cache Build with --no-cache
|
||||||
|
--target <name> Build a specific Dockerfile target (e.g. virgin|full)
|
||||||
|
--tag <image> Override the output image tag (default: ${default_tag})
|
||||||
|
-h, --help Show help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--no-cache) NO_CACHE=1; shift ;;
|
||||||
|
--missing) MISSING_ONLY=1; shift ;;
|
||||||
|
--target)
|
||||||
|
TARGET="${2:-}"
|
||||||
|
if [[ -z "${TARGET}" ]]; then
|
||||||
|
echo "ERROR: --target requires a value (e.g. virgin|full)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
IMAGE_TAG="${2:-}"
|
||||||
|
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||||
|
echo "ERROR: --tag requires a value" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: Unknown argument: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Auto-tag: if --tag not provided, derive from distro (+ target suffix)
|
||||||
|
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||||
|
IMAGE_TAG="pkgmgr-${distro}"
|
||||||
|
if [[ -n "${TARGET}" ]]; then
|
||||||
|
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_IMAGE="$(resolve_base_image "$distro")"
|
||||||
|
|
||||||
|
if [[ "${MISSING_ONLY}" == "1" ]]; then
|
||||||
|
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
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "[build] Building image: ${IMAGE_TAG}"
|
||||||
|
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
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
build_args+=(-t "${IMAGE_TAG}" .)
|
||||||
|
|
||||||
|
docker build "${build_args[@]}"
|
||||||
@@ -1,53 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
echo "[docker] Starting package-manager container"
|
echo "[docker] Starting package-manager container"
|
||||||
@@ -68,16 +21,10 @@ cd /src
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DEV mode: rebuild package-manager from the mounted /src tree
|
# DEV mode: rebuild package-manager from the mounted /src tree
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
|
if [[ "${REINSTALL_PKGMGR:-0}" == "1" ]]; then
|
||||||
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
|
echo "[docker] DEV mode enabled (REINSTALL_PKGMGR=1)"
|
||||||
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
|
echo "[docker] Rebuilding package-manager from /src via scripts/installation/package.sh..."
|
||||||
|
bash scripts/installation/package.sh || exit 1
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,47 +4,184 @@ set -euo pipefail
|
|||||||
echo "[init-nix] Starting Nix initialization..."
|
echo "[init-nix] Starting Nix initialization..."
|
||||||
|
|
||||||
NIX_INSTALL_URL="${NIX_INSTALL_URL:-https://nixos.org/nix/install}"
|
NIX_INSTALL_URL="${NIX_INSTALL_URL:-https://nixos.org/nix/install}"
|
||||||
NIX_DOWNLOAD_MAX_TIME=300 # 5 minutes
|
NIX_DOWNLOAD_MAX_TIME="${NIX_DOWNLOAD_MAX_TIME:-300}"
|
||||||
NIX_DOWNLOAD_SLEEP_INTERVAL=20 # 20 seconds
|
NIX_DOWNLOAD_SLEEP_INTERVAL="${NIX_DOWNLOAD_SLEEP_INTERVAL:-20}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Detect whether we are inside a container (Docker/Podman/etc.)
|
# Detect whether we are inside a container (Docker/Podman/etc.)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
is_container() {
|
is_container() {
|
||||||
if [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]]; then
|
[[ -f /.dockerenv || -f /run/.containerenv ]] && return 0
|
||||||
return 0
|
grep -qiE 'docker|container|podman|lxc' /proc/1/cgroup 2>/dev/null && return 0
|
||||||
fi
|
[[ -n "${container:-}" ]] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if grep -qiE 'docker|container|podman|lxc' /proc/1/cgroup 2>/dev/null; then
|
# ---------------------------------------------------------------------------
|
||||||
return 0
|
# 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
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -n "${container:-}" ]]; then
|
# ---------------------------------------------------------------------------
|
||||||
return 0
|
# Resolve a path to a real executable (follows symlinks)
|
||||||
fi
|
# ---------------------------------------------------------------------------
|
||||||
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure Nix binaries are on PATH (multi-user or single-user)
|
# Ensure globally reachable nix symlink(s) (CI / non-login shells) - root only
|
||||||
|
#
|
||||||
|
# Key rule:
|
||||||
|
# - Never overwrite distro-managed nix locations (Arch may ship nix in /usr/sbin).
|
||||||
|
# - But for sudo secure_path (CentOS), /usr/local/bin is often NOT included.
|
||||||
|
# Therefore: also create /usr/bin/nix (and /usr/sbin/nix) ONLY if they do not exist.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
ensure_nix_on_path() {
|
ensure_global_nix_symlinks() {
|
||||||
if [[ -x /nix/var/nix/profiles/default/bin/nix ]]; then
|
local nix_bin="${1:-}"
|
||||||
export PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
|
||||||
|
[[ -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
|
fi
|
||||||
|
|
||||||
if [[ -x "${HOME}/.nix-profile/bin/nix" ]]; then
|
# Always link to the real executable to avoid /usr/local/bin/nix -> /usr/local/bin/nix
|
||||||
export PATH="${HOME}/.nix-profile/bin:${PATH}"
|
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
|
fi
|
||||||
|
|
||||||
if [[ -x /home/nix/.nix-profile/bin/nix ]]; then
|
local target current_real
|
||||||
export PATH="/home/nix/.nix-profile/bin:${PATH}"
|
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 'init-nix.sh' "$HOME/.profile" 2>/dev/null; then
|
||||||
|
cat >>"$HOME/.profile" <<'EOF'
|
||||||
|
|
||||||
|
# PATH for nix (added by package-manager init-nix.sh)
|
||||||
|
if [ -d "$HOME/.local/bin" ]; then
|
||||||
|
PATH="$HOME/.local/bin:$PATH"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure Nix build group and users exist (build-users-group = nixbld)
|
# Ensure Nix build group and users exist (build-users-group = nixbld) - root only
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
ensure_nix_build_group() {
|
ensure_nix_build_group() {
|
||||||
if ! getent group nixbld >/dev/null 2>&1; then
|
if ! getent group nixbld >/dev/null 2>&1; then
|
||||||
@@ -69,73 +206,84 @@ install_nix_with_retry() {
|
|||||||
local run_as="${2:-}"
|
local run_as="${2:-}"
|
||||||
local installer elapsed=0 mode_flag
|
local installer elapsed=0 mode_flag
|
||||||
|
|
||||||
case "${mode}" in
|
case "$mode" in
|
||||||
daemon) mode_flag="--daemon" ;;
|
daemon) mode_flag="--daemon" ;;
|
||||||
no-daemon) mode_flag="--no-daemon" ;;
|
no-daemon) mode_flag="--no-daemon" ;;
|
||||||
*)
|
*)
|
||||||
echo "[init-nix] ERROR: Invalid mode '${mode}', expected 'daemon' or 'no-daemon'."
|
echo "[init-nix] ERROR: Invalid mode '$mode' (expected 'daemon' or 'no-daemon')."
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
installer="$(mktemp -t nix-installer.XXXXXX)"
|
installer="$(mktemp -t nix-installer.XXXXXX)"
|
||||||
|
chmod 0644 "$installer"
|
||||||
|
|
||||||
echo "[init-nix] Downloading Nix installer from ${NIX_INSTALL_URL} with retry (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
echo "[init-nix] Downloading Nix installer from $NIX_INSTALL_URL (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if curl -fL "${NIX_INSTALL_URL}" -o "${installer}"; then
|
if curl -fL "$NIX_INSTALL_URL" -o "$installer"; then
|
||||||
echo "[init-nix] Successfully downloaded Nix installer to ${installer}"
|
echo "[init-nix] Successfully downloaded installer to $installer"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local curl_exit=$?
|
|
||||||
echo "[init-nix] WARNING: Failed to download Nix installer (curl exit code ${curl_exit})."
|
|
||||||
|
|
||||||
elapsed=$((elapsed + NIX_DOWNLOAD_SLEEP_INTERVAL))
|
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
|
if (( elapsed >= NIX_DOWNLOAD_MAX_TIME )); then
|
||||||
echo "[init-nix] ERROR: Giving up after ${elapsed}s trying to download Nix installer."
|
echo "[init-nix] ERROR: Giving up after ${elapsed}s trying to download Nix installer."
|
||||||
rm -f "${installer}"
|
rm -f "$installer"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[init-nix] Retrying in ${NIX_DOWNLOAD_SLEEP_INTERVAL}s (elapsed: ${elapsed}s/${NIX_DOWNLOAD_MAX_TIME}s)..."
|
sleep "$NIX_DOWNLOAD_SLEEP_INTERVAL"
|
||||||
sleep "${NIX_DOWNLOAD_SLEEP_INTERVAL}"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -n "${run_as}" ]]; then
|
if [[ -n "$run_as" ]]; then
|
||||||
echo "[init-nix] Running installer as user '${run_as}' with mode '${mode}'..."
|
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
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
sudo -u "${run_as}" bash -lc "sh '${installer}' ${mode_flag}"
|
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
|
||||||
else
|
else
|
||||||
su - "${run_as}" -c "sh '${installer}' ${mode_flag}"
|
su - "$run_as" -c "sh '$installer' $mode_flag"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "[init-nix] Running installer as current user with mode '${mode}'..."
|
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
||||||
sh "${installer}" "${mode_flag}"
|
sh "$installer" "$mode_flag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "${installer}"
|
rm -f "$installer"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
main() {
|
main() {
|
||||||
# Fast path: Nix already available
|
# Fast path: already available
|
||||||
if command -v nix >/dev/null 2>&1; then
|
if command -v nix >/dev/null 2>&1; then
|
||||||
echo "[init-nix] Nix already available on PATH: $(command -v nix)"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_nix_on_path
|
ensure_nix_on_path
|
||||||
|
|
||||||
if command -v nix >/dev/null 2>&1; then
|
if command -v nix >/dev/null 2>&1; then
|
||||||
echo "[init-nix] Nix found after adjusting PATH: $(command -v nix)"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[init-nix] Nix not found, starting installation logic..."
|
|
||||||
|
|
||||||
local IN_CONTAINER=0
|
local IN_CONTAINER=0
|
||||||
if is_container; then
|
if is_container; then
|
||||||
IN_CONTAINER=1
|
IN_CONTAINER=1
|
||||||
@@ -147,8 +295,8 @@ main() {
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Container + root: dedicated "nix" user, single-user install
|
# Container + root: dedicated "nix" user, single-user install
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
|
if [[ "$IN_CONTAINER" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
|
||||||
echo "[init-nix] Container + root – installing as 'nix' user (single-user)."
|
echo "[init-nix] Container + root: installing as 'nix' user (single-user)."
|
||||||
|
|
||||||
ensure_nix_build_group
|
ensure_nix_build_group
|
||||||
|
|
||||||
@@ -156,8 +304,8 @@ main() {
|
|||||||
echo "[init-nix] Creating user 'nix'..."
|
echo "[init-nix] Creating user 'nix'..."
|
||||||
local BASH_SHELL
|
local BASH_SHELL
|
||||||
BASH_SHELL="$(command -v bash || true)"
|
BASH_SHELL="$(command -v bash || true)"
|
||||||
[[ -z "${BASH_SHELL}" ]] && BASH_SHELL="/bin/sh"
|
[[ -z "$BASH_SHELL" ]] && BASH_SHELL="/bin/sh"
|
||||||
useradd -m -r -g nixbld -s "${BASH_SHELL}" nix
|
useradd -m -r -g nixbld -s "$BASH_SHELL" nix
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -d /nix ]]; then
|
if [[ ! -d /nix ]]; then
|
||||||
@@ -168,78 +316,69 @@ main() {
|
|||||||
local current_owner current_group
|
local current_owner current_group
|
||||||
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
|
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
|
||||||
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
|
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
|
||||||
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
|
if [[ "$current_owner" != "nix" || "$current_group" != "nixbld" ]]; then
|
||||||
echo "[init-nix] Fixing /nix ownership from ${current_owner}:${current_group} to nix:nixbld..."
|
echo "[init-nix] Fixing /nix ownership from $current_owner:$current_group to nix:nixbld..."
|
||||||
chown -R nix:nixbld /nix
|
chown -R nix:nixbld /nix
|
||||||
fi
|
fi
|
||||||
if [[ ! -w /nix ]]; then
|
|
||||||
echo "[init-nix] WARNING: /nix is not writable after chown; Nix installer may fail."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install_nix_with_retry "no-daemon" "nix"
|
install_nix_with_retry "no-daemon" "nix"
|
||||||
|
|
||||||
ensure_nix_on_path
|
ensure_nix_on_path
|
||||||
|
|
||||||
if [[ -x /home/nix/.nix-profile/bin/nix && ! -e /usr/local/bin/nix ]]; then
|
# Ensure stable global symlink(s) (sudo secure_path friendly)
|
||||||
echo "[init-nix] Creating /usr/local/bin/nix symlink -> /home/nix/.nix-profile/bin/nix"
|
ensure_global_nix_symlinks "/home/nix/.nix-profile/bin/nix"
|
||||||
ln -s /home/nix/.nix-profile/bin/nix /usr/local/bin/nix
|
|
||||||
|
# Ensure non-root users can traverse and execute nix user profile
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Host (no container)
|
# Host (no container)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
elif [[ "${IN_CONTAINER}" -eq 0 ]]; then
|
else
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
echo "[init-nix] Host with systemd – using multi-user install (--daemon)."
|
echo "[init-nix] Host with systemd: using multi-user install (--daemon)."
|
||||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||||
ensure_nix_build_group
|
ensure_nix_build_group
|
||||||
fi
|
fi
|
||||||
install_nix_with_retry "daemon"
|
install_nix_with_retry "daemon"
|
||||||
else
|
else
|
||||||
|
echo "[init-nix] No systemd detected: using single-user install (--no-daemon)."
|
||||||
if [[ "${EUID:-0}" -eq 0 ]]; then
|
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||||
echo "[init-nix] Host without systemd as root – using single-user install (--no-daemon)."
|
|
||||||
ensure_nix_build_group
|
ensure_nix_build_group
|
||||||
else
|
|
||||||
echo "[init-nix] Host without systemd as non-root – using single-user install (--no-daemon)."
|
|
||||||
fi
|
fi
|
||||||
install_nix_with_retry "no-daemon"
|
install_nix_with_retry "no-daemon"
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# After installation: PATH + /etc/profile
|
# After install: PATH + symlink(s)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
ensure_nix_on_path
|
ensure_nix_on_path
|
||||||
|
|
||||||
if ! command -v nix >/dev/null 2>&1; then
|
local nix_bin_post
|
||||||
echo "[init-nix] WARNING: Nix installation finished, but 'nix' is still not on PATH."
|
nix_bin_post="$(resolve_nix_bin 2>/dev/null || true)"
|
||||||
echo "[init-nix] You may need to source your shell profile manually."
|
|
||||||
|
if [[ "${EUID:-0}" -eq 0 ]]; then
|
||||||
|
ensure_global_nix_symlinks "$nix_bin_post"
|
||||||
else
|
else
|
||||||
echo "[init-nix] Nix successfully installed at: $(command -v nix)"
|
ensure_user_nix_symlink "$nix_bin_post"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -w /etc/profile ]] && ! grep -q 'Nix profiles' /etc/profile 2>/dev/null; then
|
# Final verification (must succeed for CI)
|
||||||
cat <<'EOF' >> /etc/profile
|
if ! command -v nix >/dev/null 2>&1; then
|
||||||
|
echo "[init-nix] ERROR: nix not found after installation."
|
||||||
# Nix profiles (added by package-manager init-nix.sh)
|
echo "[init-nix] DEBUG: resolved nix path = ${nix_bin_post:-<empty>}"
|
||||||
if [ -d /nix/var/nix/profiles/default/bin ]; then
|
echo "[init-nix] DEBUG: PATH = $PATH"
|
||||||
PATH="/nix/var/nix/profiles/default/bin:$PATH"
|
exit 1
|
||||||
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
|
fi
|
||||||
|
|
||||||
|
echo "[init-nix] Nix successfully available at: $(command -v nix)"
|
||||||
echo "[init-nix] Nix initialization complete."
|
echo "[init-nix] Nix initialization complete."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pacman -S --noconfirm --needed \
|
|||||||
rsync \
|
rsync \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
python \
|
||||||
xz
|
xz
|
||||||
|
|
||||||
pacman -Scc --noconfirm
|
pacman -Scc --noconfirm
|
||||||
|
|||||||
@@ -1,30 +1,64 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||||
PKG_DIR="${PROJECT_ROOT}/packaging/arch"
|
|
||||||
|
|
||||||
if [[ ! -f "${PKG_DIR}/PKGBUILD" ]]; then
|
# We must not build inside /src (mounted repo). Build in /tmp to avoid permission issues.
|
||||||
echo "[arch/package] ERROR: PKGBUILD not found in ${PKG_DIR}"
|
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
|
exit 1
|
||||||
fi
|
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] Syncing project sources to ${BUILD_ROOT}..."
|
||||||
echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
# Keep it simple: copy everything; adjust excludes if needed later.
|
||||||
chown -R aur_builder:aur_builder "${PKG_DIR}"
|
rsync -a --delete \
|
||||||
su aur_builder -c "cd '${PKG_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
--exclude '.git' \
|
||||||
else
|
--exclude '.venv' \
|
||||||
echo "[arch/package] WARNING: user 'aur_builder' not found, running makepkg as current user..."
|
--exclude '.venvs' \
|
||||||
rm -f package-manager-*.pkg.tar.*
|
--exclude '__pycache__' \
|
||||||
makepkg --noconfirm --clean --nodeps
|
--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
|
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..."
|
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."
|
echo "[arch/package] Done."
|
||||||
|
|||||||
@@ -13,9 +13,64 @@ dnf -y install \
|
|||||||
bash \
|
bash \
|
||||||
curl-minimal \
|
curl-minimal \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
sudo \
|
sudo \
|
||||||
xz
|
xz
|
||||||
|
|
||||||
dnf clean all
|
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."
|
echo "[centos/dependencies] Done."
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||||||
bash \
|
bash \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
xz-utils
|
xz-utils
|
||||||
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -1,87 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
# main.sh
|
echo "[installation/install] Warning: Installation is just possible via root."
|
||||||
#
|
|
||||||
# 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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
echo "[installation] Running as root (EUID=0)."
|
||||||
# 2) Root mode: system / distro-level installation
|
echo "[installation] Install Package Dependencies..."
|
||||||
# ------------------------------------------------------------
|
bash scripts/installation/dependencies.sh
|
||||||
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
echo "[installation] Install Distribution Package..."
|
||||||
echo "[installation/main] Running as root (EUID=0)."
|
bash scripts/installation/package.sh
|
||||||
echo "[installation/main] Skipping user virtualenv and shell RC modifications."
|
echo "[installation] Root/system setup complete."
|
||||||
echo "[installation/main] Delegating to scripts/installation/run-package.sh..."
|
exit 0
|
||||||
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."
|
|
||||||
|
|||||||
@@ -10,26 +10,26 @@ OS_ID="$(detect_os_id)"
|
|||||||
|
|
||||||
# Map Manjaro to Arch
|
# Map Manjaro to Arch
|
||||||
if [[ "${OS_ID}" == "manjaro" ]]; then
|
if [[ "${OS_ID}" == "manjaro" ]]; then
|
||||||
echo "[run-package] Mapping OS 'manjaro' → 'arch'"
|
echo "[package] Mapping OS 'manjaro' → 'arch'"
|
||||||
OS_ID="arch"
|
OS_ID="arch"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[run-package] Detected OS: ${OS_ID}"
|
echo "[package] Detected OS: ${OS_ID}"
|
||||||
|
|
||||||
case "${OS_ID}" in
|
case "${OS_ID}" in
|
||||||
arch|debian|ubuntu|fedora|centos)
|
arch|debian|ubuntu|fedora|centos)
|
||||||
PKG_SCRIPT="${SCRIPT_DIR}/${OS_ID}/package.sh"
|
PKG_SCRIPT="${SCRIPT_DIR}/${OS_ID}/package.sh"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[run-package] Unsupported OS: ${OS_ID}"
|
echo "[package] Unsupported OS: ${OS_ID}"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ ! -f "${PKG_SCRIPT}" ]]; then
|
if [[ ! -f "${PKG_SCRIPT}" ]]; then
|
||||||
echo "[run-package] Package script not found: ${PKG_SCRIPT}"
|
echo "[package] Package script not found: ${PKG_SCRIPT}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[run-package] Executing: ${PKG_SCRIPT}"
|
echo "[package] Executing: ${PKG_SCRIPT}"
|
||||||
exec bash "${PKG_SCRIPT}"
|
exec bash "${PKG_SCRIPT}"
|
||||||
@@ -14,6 +14,9 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||||||
rsync \
|
rsync \
|
||||||
bash \
|
bash \
|
||||||
curl \
|
curl \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
xz-utils
|
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."
|
|
||||||
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 "$(pwd):/src" \
|
||||||
-v "pkgmgr_nix_store_${distro}:/nix" \
|
-v "pkgmgr_nix_store_${distro}:/nix" \
|
||||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||||
-e PKGMGR_DEV=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
--workdir /src \
|
--workdir /src \
|
||||||
"package-manager-test-${distro}" \
|
"pkgmgr-${distro}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="package-manager-test-$distro"
|
IMAGE="pkgmgr-$distro"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
echo ">>> Testing container: $IMAGE"
|
echo ">>> Testing VENV: $IMAGE"
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
echo "[test-container] Inspect image metadata:"
|
echo "[test-env-virtual] Inspect image metadata:"
|
||||||
docker image inspect "$IMAGE" | sed -n '1,40p'
|
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
|
echo
|
||||||
|
|
||||||
# Run the command and capture the output
|
# Run the command and capture the output
|
||||||
if OUTPUT=$(docker run --rm \
|
if OUTPUT=$(docker run --rm \
|
||||||
-e PKGMGR_DEV=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-v pkgmgr_nix_store_${distro}:/nix \
|
-v pkgmgr_nix_store_${distro}:/nix \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/src" \
|
||||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||||
"$IMAGE" 2>&1); then
|
"$IMAGE" 2>&1); then
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
echo
|
echo
|
||||||
echo "[test-container] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
echo "[test-env-virtual] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
echo
|
echo
|
||||||
echo "[test-container] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
echo "[test-env-virtual] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -10,9 +10,9 @@ docker run --rm \
|
|||||||
-v pkgmgr_nix_store_${distro}:/nix \
|
-v pkgmgr_nix_store_${distro}:/nix \
|
||||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||||
--workdir /src \
|
--workdir /src \
|
||||||
-e PKGMGR_DEV=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
"package-manager-test-${distro}" \
|
"pkgmgr-${distro}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -e;
|
set -e;
|
||||||
git config --global --add safe.directory /src || true;
|
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_cache_${distro}:/root/.cache/nix" \
|
||||||
-v pkgmgr_nix_store_${distro}:/nix \
|
-v pkgmgr_nix_store_${distro}:/nix \
|
||||||
--workdir /src \
|
--workdir /src \
|
||||||
-e PKGMGR_DEV=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
"package-manager-test-${distro}" \
|
"pkgmgr-${distro}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -e;
|
set -e;
|
||||||
git config --global --add safe.directory /src || true;
|
git config --global --add safe.directory /src || true;
|
||||||
|
|||||||
@@ -1,235 +1,14 @@
|
|||||||
# pkgmgr/actions/branch/__init__.py
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
"""
|
||||||
High-level helpers for branch-related operations.
|
Public API for branch actions.
|
||||||
|
|
||||||
This module encapsulates the actual Git logic so the CLI layer
|
|
||||||
(pkgmgr.cli.commands.branch) stays thin and testable.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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
|
__all__ = [
|
||||||
|
"open_branch",
|
||||||
from pkgmgr.core.git import run_git, GitError, get_current_branch
|
"close_branch",
|
||||||
|
"drop_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
|
|
||||||
|
|||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
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.identifier import get_repo_identifier
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
@@ -63,7 +63,7 @@ def _ensure_repo_dir(
|
|||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
) -> str | None:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Compute and, if necessary, clone the repository directory.
|
Compute and, if necessary, clone the repository directory.
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Iterable, TYPE_CHECKING
|
from typing import Iterable, TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
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."""
|
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return None
|
return None
|
||||||
@@ -75,7 +75,7 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
|
|||||||
return False
|
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."""
|
"""Return the first *.spec file in repo_dir, if any."""
|
||||||
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
||||||
if not matches:
|
if not matches:
|
||||||
@@ -360,7 +360,7 @@ def detect_capabilities(
|
|||||||
|
|
||||||
def resolve_effective_capabilities(
|
def resolve_effective_capabilities(
|
||||||
ctx: "RepoContext",
|
ctx: "RepoContext",
|
||||||
layers: Iterable[str] | None = None,
|
layers: Optional[Iterable[str]] = None,
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
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 abc import ABC, abstractmethod
|
||||||
from typing import Set
|
from typing import Set, Optional
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||||
@@ -24,7 +24,7 @@ class BaseInstaller(ABC):
|
|||||||
# Examples: "nix", "python", "makefile".
|
# Examples: "nix", "python", "makefile".
|
||||||
# This is used by capability matchers to decide which patterns to
|
# This is used by capability matchers to decide which patterns to
|
||||||
# search for in the repository.
|
# search for in the repository.
|
||||||
layer: str | None = None
|
layer: Optional[str] = None
|
||||||
|
|
||||||
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ apt/dpkg tooling are available.
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -67,7 +67,7 @@ class DebianControlInstaller(BaseInstaller):
|
|||||||
pattern = os.path.join(parent, "*.deb")
|
pattern = os.path.join(parent, "*.deb")
|
||||||
return sorted(glob.glob(pattern))
|
return sorted(glob.glob(pattern))
|
||||||
|
|
||||||
def _privileged_prefix(self) -> str | None:
|
def _privileged_prefix(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Determine how to run privileged commands:
|
Determine how to run privileged commands:
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import List, Optional, Set
|
|
||||||
|
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
from pkgmgr.core.git import GitError, run_git
|
from pkgmgr.core.git import GitError, run_git
|
||||||
|
from typing import List, Optional, Set
|
||||||
|
|
||||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from .workflow import release
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["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
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,77 +6,87 @@ from pkgmgr.core.git import GitError
|
|||||||
|
|
||||||
|
|
||||||
def run_git_command(cmd: str) -> None:
|
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}")
|
print(f"[GIT] {cmd}")
|
||||||
try:
|
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:
|
except subprocess.CalledProcessError as exc:
|
||||||
print(f"[ERROR] Git command failed: {cmd}")
|
print(f"[ERROR] Git command failed: {cmd}")
|
||||||
print(f" Exit code: {exc.returncode}")
|
print(f" Exit code: {exc.returncode}")
|
||||||
if exc.stdout:
|
if exc.stdout:
|
||||||
print("--- stdout ---")
|
print("\n" + exc.stdout)
|
||||||
print(exc.stdout)
|
|
||||||
if exc.stderr:
|
if exc.stderr:
|
||||||
print("--- stderr ---")
|
print("\n" + exc.stderr)
|
||||||
print(exc.stderr)
|
|
||||||
raise GitError(f"Git command failed: {cmd}") from exc
|
raise GitError(f"Git command failed: {cmd}") from exc
|
||||||
|
|
||||||
|
|
||||||
def sync_branch_with_remote(branch: str, preview: bool = False) -> None:
|
def _capture(cmd: str) -> str:
|
||||||
"""
|
res = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||||
Ensure the local main/master branch is up-to-date before tagging.
|
return (res.stdout or "").strip()
|
||||||
|
|
||||||
Behaviour:
|
|
||||||
- For main/master: run 'git fetch origin' and 'git pull origin <branch>'.
|
def ensure_clean_and_synced(preview: bool = False) -> None:
|
||||||
- For all other branches: only log that no automatic sync is performed.
|
|
||||||
"""
|
"""
|
||||||
if branch not in ("main", "master"):
|
Always run a pull BEFORE modifying anything.
|
||||||
print(
|
Uses --ff-only to avoid creating merge commits automatically.
|
||||||
f"[INFO] Skipping automatic git pull for non-main/master branch "
|
If no upstream is configured, we skip.
|
||||||
f"{branch}."
|
"""
|
||||||
)
|
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
|
return
|
||||||
|
|
||||||
print(
|
|
||||||
f"[INFO] Updating branch {branch} from origin before creating tags..."
|
|
||||||
)
|
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print("[PREVIEW] Would run: git fetch origin")
|
print("[PREVIEW] Would run: git fetch origin --prune --tags --force")
|
||||||
print(f"[PREVIEW] Would run: git pull origin {branch}")
|
print("[PREVIEW] Would run: git pull --ff-only")
|
||||||
return
|
return
|
||||||
|
|
||||||
run_git_command("git fetch origin")
|
print("[INFO] Syncing with remote before making any changes...")
|
||||||
run_git_command(f"git pull origin {branch}")
|
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:
|
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Move the floating 'latest' tag to the newly created release tag.
|
Move the floating 'latest' tag to the newly created release tag.
|
||||||
|
|
||||||
Implementation details:
|
Notes:
|
||||||
- We explicitly dereference the tag object via `<tag>^{}` so that
|
- We dereference the tag object via `<tag>^{}` so that 'latest' points to the commit.
|
||||||
'latest' always points at the underlying commit, not at another tag.
|
- 'latest' is forced (floating tag), therefore the push uses --force.
|
||||||
- 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".
|
|
||||||
"""
|
"""
|
||||||
target_ref = f"{new_tag}^{{}}"
|
target_ref = f"{new_tag}^{{}}"
|
||||||
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} "
|
print(
|
||||||
f'-m "Floating latest tag for {new_tag}"')
|
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")
|
print("[PREVIEW] Would run: git push origin latest --force")
|
||||||
return
|
return
|
||||||
|
|
||||||
run_git_command(
|
run_git_command(
|
||||||
f'git tag -f -a latest {target_ref} '
|
f'git tag -f -a latest {target_ref} -m "Floating latest tag for {new_tag}"'
|
||||||
f'-m "Floating latest tag for {new_tag}"'
|
|
||||||
)
|
)
|
||||||
run_git_command("git push origin latest --force")
|
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,
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pkgmgr.cli.context import CLIContext
|
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:
|
def handle_branch(args, ctx: CLIContext) -> None:
|
||||||
@@ -12,7 +12,8 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
|
|
||||||
Currently supported:
|
Currently supported:
|
||||||
- pkgmgr branch open [<name>] [--base <branch>]
|
- 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":
|
if args.subcommand == "open":
|
||||||
open_branch(
|
open_branch(
|
||||||
@@ -27,6 +28,16 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
name=getattr(args, "name", None),
|
name=getattr(args, "name", None),
|
||||||
base_branch=getattr(args, "base", "main"),
|
base_branch=getattr(args, "base", "main"),
|
||||||
cwd=".",
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
|||||||
return {"repositories": []}
|
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
|
Find the directory inside the installed pkgmgr package OR the
|
||||||
project root that contains default config files.
|
project root that contains default config files.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def add_branch_subparsers(
|
|||||||
"""
|
"""
|
||||||
branch_parser = subparsers.add_parser(
|
branch_parser = subparsers.add_parser(
|
||||||
"branch",
|
"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(
|
branch_subparsers = branch_parser.add_subparsers(
|
||||||
dest="subcommand",
|
dest="subcommand",
|
||||||
@@ -22,6 +22,9 @@ def add_branch_subparsers(
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# branch open
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
branch_open = branch_subparsers.add_parser(
|
branch_open = branch_subparsers.add_parser(
|
||||||
"open",
|
"open",
|
||||||
help="Create and push a new branch on top of a base branch",
|
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)",
|
help="Base branch to create the new branch from (default: main)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# branch close
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
branch_close = branch_subparsers.add_parser(
|
branch_close = branch_subparsers.add_parser(
|
||||||
"close",
|
"close",
|
||||||
help="Merge a feature branch into base and delete it",
|
help="Merge a feature branch into base and delete it",
|
||||||
@@ -60,3 +66,39 @@ def add_branch_subparsers(
|
|||||||
"internally if main does not exist)"
|
"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 os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Optional
|
||||||
# pkgmgr/run_command.py
|
# pkgmgr/run_command.py
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def _repo_key(repo: Repo) -> Tuple[str, str, str]:
|
|||||||
def _merge_repo_lists(
|
def _merge_repo_lists(
|
||||||
base_list: List[Repo],
|
base_list: List[Repo],
|
||||||
new_list: List[Repo],
|
new_list: List[Repo],
|
||||||
category_name: str | None = None,
|
category_name: Optional[str] = None,
|
||||||
) -> List[Repo]:
|
) -> List[Repo]:
|
||||||
"""
|
"""
|
||||||
Merge two repository lists, matching by (provider, account, repository).
|
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(
|
def _load_layer_dir(
|
||||||
config_dir: Path,
|
config_dir: Path,
|
||||||
skip_filename: str | None = None,
|
skip_filename: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Load all *.yml/*.yaml from a directory as layered defaults.
|
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,
|
- the config/config.yaml is present,
|
||||||
- and it is safe to perform real git operations.
|
- 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 os
|
||||||
import sys
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from test_install_pkgmgr_shallow import (
|
from test_install_pkgmgr_shallow import (
|
||||||
@@ -22,55 +24,35 @@ from test_install_pkgmgr_shallow import (
|
|||||||
|
|
||||||
|
|
||||||
class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
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
|
Run a real CLI command and raise a helpful assertion on failure.
|
||||||
extra diagnostics if the command exits with a non-zero code.
|
|
||||||
"""
|
"""
|
||||||
cmd_repr = "pkgmgr update --all --clone-mode https --no-verification"
|
cmd_repr = " ".join(cmd)
|
||||||
original_argv = sys.argv
|
env = os.environ.copy()
|
||||||
try:
|
|
||||||
sys.argv = [
|
|
||||||
"pkgmgr",
|
|
||||||
"update",
|
|
||||||
"--all",
|
|
||||||
"--clone-mode",
|
|
||||||
"https",
|
|
||||||
"--no-verification",
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute main.py as if it was called from CLI.
|
print(f"\n[TEST] Running ({label}): {cmd_repr}")
|
||||||
# This will run the full update pipeline inside the container.
|
subprocess.run(
|
||||||
runpy.run_module("main", run_name="__main__")
|
cmd,
|
||||||
except SystemExit as exc:
|
check=True,
|
||||||
# Convert SystemExit into a more helpful assertion with debug output.
|
cwd=os.getcwd(),
|
||||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
env=env,
|
||||||
|
text=True,
|
||||||
print("\n[TEST] pkgmgr update --all failed with SystemExit")
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print(f"\n[TEST] Command failed ({label})")
|
||||||
print(f"[TEST] Command : {cmd_repr}")
|
print(f"[TEST] Command : {cmd_repr}")
|
||||||
print(f"[TEST] Exit code: {exit_code}")
|
print(f"[TEST] Exit code: {exc.returncode}")
|
||||||
|
|
||||||
# Additional Nix profile debug on failure (useful if any update
|
nix_profile_list_debug(f"ON FAILURE ({label})")
|
||||||
# step interacts with Nix-based tooling).
|
|
||||||
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
|
|
||||||
|
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
f"{cmd_repr!r} failed with exit code {exit_code}. "
|
f"({label}) {cmd_repr!r} failed with exit code {exc.returncode}. "
|
||||||
"Scroll up to see the full pkgmgr/make output inside the container."
|
"Scroll up to see the full pkgmgr/nix output inside the container."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
finally:
|
def _common_setup(self) -> None:
|
||||||
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.
|
|
||||||
"""
|
|
||||||
# Debug before cleanup
|
# Debug before cleanup
|
||||||
nix_profile_list_debug("BEFORE CLEANUP")
|
nix_profile_list_debug("BEFORE CLEANUP")
|
||||||
|
|
||||||
@@ -81,11 +63,28 @@ class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
|||||||
# Debug after cleanup
|
# Debug after cleanup
|
||||||
nix_profile_list_debug("AFTER CLEANUP")
|
nix_profile_list_debug("AFTER CLEANUP")
|
||||||
|
|
||||||
# Run the actual update with extended diagnostics
|
def test_update_all_repositories_https_pkgmgr(self) -> None:
|
||||||
self._run_pkgmgr_update_all_https()
|
"""
|
||||||
|
Run: pkgmgr update --all --clone-mode https --no-verification
|
||||||
|
"""
|
||||||
|
self._common_setup()
|
||||||
|
|
||||||
# After successful update: show `pkgmgr --help`
|
args = ["update", "--all", "--clone-mode", "https", "--no-verification"]
|
||||||
# via interactive bash (same helper as in the other integration tests).
|
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()
|
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.core.git import GitError
|
||||||
from pkgmgr.actions.release.git_ops import (
|
from pkgmgr.actions.release.git_ops import (
|
||||||
|
ensure_clean_and_synced,
|
||||||
|
is_highest_version_tag,
|
||||||
run_git_command,
|
run_git_command,
|
||||||
sync_branch_with_remote,
|
|
||||||
update_latest_tag,
|
update_latest_tag,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +15,13 @@ from pkgmgr.actions.release.git_ops import (
|
|||||||
class TestRunGitCommand(unittest.TestCase):
|
class TestRunGitCommand(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
def test_run_git_command_success(self, mock_run) -> None:
|
def test_run_git_command_success(self, mock_run) -> None:
|
||||||
# No exception means success
|
|
||||||
run_git_command("git status")
|
run_git_command("git status")
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
args, kwargs = mock_run.call_args
|
args, kwargs = mock_run.call_args
|
||||||
self.assertIn("git status", args[0])
|
self.assertIn("git status", args[0])
|
||||||
self.assertTrue(kwargs.get("check"))
|
self.assertTrue(kwargs.get("check"))
|
||||||
|
self.assertTrue(kwargs.get("capture_output"))
|
||||||
|
self.assertTrue(kwargs.get("text"))
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
def test_run_git_command_failure_raises_git_error(self, mock_run) -> None:
|
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")
|
run_git_command("git status")
|
||||||
|
|
||||||
|
|
||||||
class TestSyncBranchWithRemote(unittest.TestCase):
|
class TestEnsureCleanAndSynced(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
def _fake_run(self, cmd: str, *args, **kwargs):
|
||||||
def test_sync_branch_with_remote_skips_non_main_master(
|
class R:
|
||||||
self,
|
def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0):
|
||||||
mock_run_git_command,
|
self.stdout = stdout
|
||||||
) -> None:
|
self.stderr = stderr
|
||||||
sync_branch_with_remote("feature/my-branch", preview=False)
|
self.returncode = returncode
|
||||||
mock_run_git_command.assert_not_called()
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
# upstream detection
|
||||||
def test_sync_branch_with_remote_preview_on_main_does_not_run_git(
|
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||||
self,
|
return R(stdout="origin/main")
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
sync_branch_with_remote("main", preview=True)
|
|
||||||
mock_run_git_command.assert_not_called()
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
# fetch/pull should be invoked in real mode
|
||||||
def test_sync_branch_with_remote_main_runs_fetch_and_pull(
|
if cmd == "git fetch --prune --tags":
|
||||||
self,
|
return R(stdout="")
|
||||||
mock_run_git_command,
|
if cmd == "git pull --ff-only":
|
||||||
) -> None:
|
return R(stdout="Already up to date.")
|
||||||
sync_branch_with_remote("main", preview=False)
|
|
||||||
|
|
||||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
return R(stdout="")
|
||||||
self.assertIn("git fetch origin", calls)
|
|
||||||
self.assertIn("git pull origin main", calls)
|
@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):
|
class TestUpdateLatestTag(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||||
def test_update_latest_tag_preview_does_not_call_git(
|
def test_update_latest_tag_preview_does_not_call_git(self, mock_run_git_command) -> None:
|
||||||
self,
|
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
update_latest_tag("v1.2.3", preview=True)
|
update_latest_tag("v1.2.3", preview=True)
|
||||||
mock_run_git_command.assert_not_called()
|
mock_run_git_command.assert_not_called()
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||||
def test_update_latest_tag_real_calls_git_with_dereference_and_message(
|
def test_update_latest_tag_real_calls_git(self, mock_run_git_command) -> None:
|
||||||
self,
|
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
update_latest_tag("v1.2.3", preview=False)
|
update_latest_tag("v1.2.3", preview=False)
|
||||||
|
|
||||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
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(
|
self.assertIn(
|
||||||
'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for v1.2.3"',
|
'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for v1.2.3"',
|
||||||
calls,
|
calls,
|
||||||
)
|
)
|
||||||
self.assertIn("git push origin latest --force", calls)
|
self.assertIn("git push origin latest --force", calls)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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",
|
user_config_path="/tmp/config.yaml",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# open subcommand
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.branch.open_branch")
|
@patch("pkgmgr.cli.commands.branch.open_branch")
|
||||||
def test_handle_branch_open_forwards_args_to_open_branch(self, mock_open_branch) -> None:
|
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")
|
@patch("pkgmgr.cli.commands.branch.close_branch")
|
||||||
def test_handle_branch_close_forwards_args_to_close_branch(self, mock_close_branch) -> None:
|
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(
|
args = SimpleNamespace(
|
||||||
command="branch",
|
command="branch",
|
||||||
subcommand="close",
|
subcommand="close",
|
||||||
name="feature/cli-close",
|
name="feature/cli-close",
|
||||||
base="develop",
|
base="develop",
|
||||||
|
force=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = self._dummy_ctx()
|
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("name"), "feature/cli-close")
|
||||||
self.assertEqual(call_kwargs.get("base_branch"), "develop")
|
self.assertEqual(call_kwargs.get("base_branch"), "develop")
|
||||||
self.assertEqual(call_kwargs.get("cwd"), ".")
|
self.assertEqual(call_kwargs.get("cwd"), ".")
|
||||||
|
self.assertFalse(call_kwargs.get("force"))
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.branch.close_branch")
|
@patch("pkgmgr.cli.commands.branch.close_branch")
|
||||||
def test_handle_branch_close_uses_default_base_when_not_set(self, mock_close_branch) -> None:
|
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",
|
subcommand="close",
|
||||||
name=None,
|
name=None,
|
||||||
base="main",
|
base="main",
|
||||||
|
force=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = self._dummy_ctx()
|
ctx = self._dummy_ctx()
|
||||||
@@ -114,6 +122,113 @@ class TestCliBranch(unittest.TestCase):
|
|||||||
self.assertIsNone(call_kwargs.get("name"))
|
self.assertIsNone(call_kwargs.get("name"))
|
||||||
self.assertEqual(call_kwargs.get("base_branch"), "main")
|
self.assertEqual(call_kwargs.get("base_branch"), "main")
|
||||||
self.assertEqual(call_kwargs.get("cwd"), ".")
|
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:
|
def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None:
|
||||||
"""
|
"""
|
||||||
Reference in New Issue
Block a user