Compare commits

...

35 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
3642f92776 Release version 1.3.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 20:35:02 +01:00
Kevin Veen-Birkenbach
8f38edde67 **Fix Nix global symlinks for sudo secure_path without overriding distro paths**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Ensure nix is reachable for sudo on CentOS by providing /usr/bin and /usr/sbin fallbacks when absent
* Keep /usr/local/bin as primary CI path without breaking non-login shells
* Never overwrite distro-managed nix binaries (Arch-safe)
* Stabilize e2e and virgin-user tests across all distros

https://chatgpt.com/share/693c6013-af2c-800f-a1bc-baed0d29fab7
2025-12-12 20:23:29 +01:00
Kevin Veen-Birkenbach
5875441b23 **Fix Nix resolution and symlink handling on Arch without overriding system paths**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Resolve the real *nix* executable to avoid self-referential symlink loops
* Prefer distro-managed paths (*/usr/sbin*, */usr/bin*) over */usr/local*
* Restrict global symlink creation to */usr/local/bin/nix* only
* Never overwrite Arch-managed */usr/bin/nix* or */bin/nix*
* Make CI and non-login shells reliable while preserving native Arch behavior

https://chatgpt.com/share/693c6013-af2c-800f-a1bc-baed0d29fab7
2025-12-12 20:05:17 +01:00
Kevin Veen-Birkenbach
9190f0d901 Fix init-nix so it works for non-root CI shells across distros
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/693c6013-af2c-800f-a1bc-baed0d29fab7
2025-12-12 19:50:25 +01:00
Kevin Veen-Birkenbach
f227734185 **Fix init-nix for CI and Arch shells**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Simplify *init-nix.sh* while keeping container/host install paths
* Prefer canonical *nix* locations and avoid brittle PATH assumptions
* Ensure global *nix* symlinks for non-login shells (CI reliability)
* Keep retry download + nixbld bootstrap logic intact

https://chatgpt.com/share/693c6013-af2c-800f-a1bc-baed0d29fab7
2025-12-12 19:40:21 +01:00
Kevin Veen-Birkenbach
c7ef77559c Ensure nix is reachable in CI shells via robust lookup and global symlinks
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Add resolve_nix_bin to reliably locate the nix binary in non-login shells.
Create and enforce global nix symlinks for CI environments (/usr/local/bin, best-effort /usr/bin and /bin).
Apply symlink enforcement on fast path, after PATH adjustments, and post-install when running as root.
Improve warnings when nix is installed but not on PATH.

https://chatgpt.com/share/693c6013-af2c-800f-a1bc-baed0d29fab7
2025-12-12 19:33:52 +01:00
Kevin Veen-Birkenbach
2385601ed5 Persist CA bundle configuration on CentOS for Nix and HTTPS tools
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Move CA bundle detection from the Docker entrypoint to CentOS dependencies and persist it system-wide.
This ensures Nix, Git, curl, and Python HTTPS access works in virgin environments by configuring `/etc/profile.d` and `/etc/nix/nix.conf`.
Removes runtime-only CA exports from the container entrypoint and makes the setup reproducible and distro-correct.

https://chatgpt.com/share/693c5ddf-3260-800f-ac94-38c635dba307
2025-12-12 19:24:12 +01:00
Kevin Veen-Birkenbach
ac5ae95369 fix(py39): replace PEP 604 union types with Optional for Python 3.9 compatibility
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Replaced all `X | None` type hints with `Optional[X]`
- Adjusted typing imports across modules
- Fixed import order and removed invalid future-import placements
- Ensured code runs correctly on Python 3.9

https://chatgpt.com/share/693c58e1-ce70-800f-9088-5864571e024a
2025-12-12 19:02:54 +01:00
Kevin Veen-Birkenbach
31f7f47fe2 Downgraded python to 3.9 for CentOS
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 18:38:37 +01:00
Kevin Veen-Birkenbach
c8bf1c91ad **test(e2e): split update-all HTTPS integration test into pkgmgr and nix runs**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Refactored the E2E update-all test to execute real CLI commands instead of invoking *main.py*.
The test is now split into two independent cases: one running *pkgmgr update* directly and one running the same command via *nix run .#pkgmgr*.
This improves realism, diagnostics, and parity with actual user workflows inside the container.

https://chatgpt.com/share/693c52cb-cc10-800f-994b-5b2940dcf948
2025-12-12 18:37:07 +01:00
Kevin Veen-Birkenbach
f2caa68e3d fix(nix): ensure non-root access to Nix installation with strict error handling
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Ensure /home/nix and .nix-profile are accessible for non-root users,
create /usr/local/bin/nix symlink with fail-fast behavior, and replace
silent permission fixes with explicit checks, clear error messages,
and deterministic exit codes.

https://chatgpt.com/share/693c29d9-9b28-800f-a549-5661c783d968
2025-12-12 18:19:51 +01:00
Kevin Veen-Birkenbach
03c232c308 Performance optimation for workflows
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 18:07:25 +01:00
Kevin Veen-Birkenbach
e882e17737 Changed CentOS to python 3.11
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 17:26:39 +01:00
Kevin Veen-Birkenbach
b9edcf7101 Patched python version for centos
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 17:12:30 +01:00
Kevin Veen-Birkenbach
8b8ebf329f Added venv to debian and ubuntu virgin
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 17:04:34 +01:00
Kevin Veen-Birkenbach
9598c17ea0 Added python dependency to virgin container
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 16:58:12 +01:00
Kevin Veen-Birkenbach
67bd358e12 fix(docker): enforce bash shell to support pipefail across distros
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Use bash as the default shell in Docker build stages to ensure
`set -euo pipefail` works reliably on all base images, including
Ubuntu where /bin/sh does not support pipefail.

https://chatgpt.com/share/693c29d9-9b28-800f-a549-5661c783d968
2025-12-12 16:50:32 +01:00
Kevin Veen-Birkenbach
340c1700dc Added missing 'make' to ubuntu
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 16:42:45 +01:00
Kevin Veen-Birkenbach
0dfbaa0f6b ci/docker: unify image build logic and run virgin tests across all distros
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Refactor Dockerfile into multi-stage virgin/full targets and introduce a single
flag-based image build script. Standardize image naming, remove redundant build
scripts, and update Makefile targets accordingly. CI workflows now build missing
virgin images and run root and user tests consistently across all supported
distributions.

https://chatgpt.com/share/693c29d9-9b28-800f-a549-5661c783d968
2025-12-12 16:40:21 +01:00
Kevin Veen-Birkenbach
08ab9fb142 feat(ci): stabilize virgin Arch tests with Makefile install/setup and Nix Git safety
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Switch virgin root/user workflows to use *make install* + *make setup/setup-venv*
* Add Git *safe.directory /src* to avoid flake evaluation failures on mounted repos
* Enable Nix flake run in workflows and prepare */nix* for non-root execution
* Refactor Arch packaging to build in an isolated */tmp* directory via *aur_builder*
* Rename installer scripts (*run-** → *dependencies.sh* / *package.sh*) and adjust Docker entry + env var to *REINSTALL_PKGMGR*

https://chatgpt.com/share/693c29d9-9b28-800f-a549-5661c783d968
2025-12-12 15:42:25 +01:00
Kevin Veen-Birkenbach
804245325d Release version 1.2.1
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 12:32:33 +01:00
Kevin Veen-Birkenbach
c05e77658a ci(docker): remove build-time nix check and rely on runtime env test
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Why:
The Dockerfile previously validated `nix --version` during image build,
which is environment-sensitive and behaves differently in GitHub Actions
vs local/act builds due to PATH and non-login shell differences.

The actual contract is runtime availability of Nix, not build-step PATH
resolution. This is now reliably enforced by the dedicated `test-env-nix`
container test, which validates nix presence and flake execution in the
real execution environment.

This removes flaky CI behavior while keeping stronger, more accurate
coverage of the intended guarantee.

https://chatgpt.com/share/693bfbc7-63d8-800f-9ceb-728c7a58e963
2025-12-12 12:25:36 +01:00
Kevin Veen-Birkenbach
324f6db1f3 ci: split container tests into virtualenv and Nix flake environments
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Refactor CI to clearly separate virtualenv-based container tests from pure Nix flake tests across all distros (arch, debian, ubuntu, fedora, centos).
Introduce dedicated test-env-nix workflow and Makefile targets, rename former container tests to test-env-virtual, and update stable pipeline dependencies.
Improve Nix reliability in containers by fixing installer permissions and explicitly validating nix availability and version during image build and tests.
2025-12-12 12:15:40 +01:00
Kevin Veen-Birkenbach
2a69a83d71 Release version 1.2.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 10:27:56 +01:00
Kevin Veen-Birkenbach
0ec4ccbe40 **fix(release): force-fetch remote tags and align tests**
* Treat remote tags as the source of truth by force-fetching tags from *origin*
* Update preview output to reflect the real fetch behavior
* Align unit tests with the new forced tag fetch command

https://chatgpt.com/share/693bdfc3-b8b4-800f-8adc-b1dc63c56a89
2025-12-12 10:26:22 +01:00
Kevin Veen-Birkenbach
0d864867cd **feat(release): adjust highest-tag detection tests and improve logging**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Add debug output for latest vs current version tag in release git ops
* Treat “no version tags yet” as highest by definition
* Align unit tests with current *string-based* `tag >= latest` behavior
* Make tag listing mocks less brittle by matching command patterns
* Rename release init test to `test_init.py` for consistent discovery
2025-12-12 10:17:18 +01:00
Kevin Veen-Birkenbach
3ff0afe828 feat(release): refactor release workflow, tagging logic, and CLI integration
Refactor the release implementation into a dedicated workflow module with clear separation of concerns. Enforce a safe, deterministic Git flow by always syncing with the remote before modifications, pushing only the current branch and the newly created version tag, and updating the floating *latest* tag only when the released version is the highest. Add explicit user prompts for confirmation and optional branch deletion, with a forced mode to skip interaction. Update CLI wiring to pass all relevant flags, add comprehensive unit tests for the new helpers and workflow entry points, and introduce detailed documentation describing the release process, safety rules, and execution flow.
2025-12-12 10:04:24 +01:00
Kevin Veen-Birkenbach
bd74ad41f9 Release version 1.1.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-12 09:08:22 +01:00
Kevin Veen-Birkenbach
fa2a92481d Merge branch 'main' of github.com:kevinveenbirkenbach/package-manager 2025-12-12 09:08:19 +01:00
Kevin Veen-Birkenbach
6a1e001fc2 test(branch): remove obsolete test_branch.py after branch module refactor
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-container (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
The old test tests/unit/pkgmgr/actions/test_branch.py has been removed because:

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

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

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

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

Included updated `assets/map.png`.

https://chatgpt.com/share/693b1d71-ca08-800f-a000-f3be49f7efb5
2025-12-11 20:37:05 +01:00
83 changed files with 2850 additions and 1707 deletions

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
' '

View File

@@ -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
"
' '

3
.gitignore vendored
View File

@@ -27,8 +27,9 @@ 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
result result

View File

@@ -1,3 +1,88 @@
## [1.3.0] - 2025-12-12
* **Minor release Stability & CI hardening**
* Stabilized Nix resolution and global symlink handling across Arch, CentOS, Debian, and Ubuntu
* Ensured Nix works reliably in CI, sudo, login, and non-login shells without overriding distro-managed paths
* Improved error handling and deterministic behavior for non-root environments
* Refactored Docker and CI workflows for reproducible multi-distro virgin tests
* Made E2E tests more realistic by executing real CLI commands
* Fixed Python compatibility and missing dependencies on affected distros
## [1.2.1] - 2025-12-12
* **Changed**
* Split container tests into *virtualenv* and *Nix flake* environments to clearly separate Python and Nix responsibilities.
**Fixed**
* Fixed Nix installer permission issues when running under a different user in containers.
* Improved reliability of post-install Nix initialization across all distro packages.
**CI**
* Replaced generic container tests with explicit environment checks.
* Validate Nix availability via *nix flake* tests instead of Docker build-time side effects.
## [1.2.0] - 2025-12-12
* **Release workflow overhaul**
* Introduced a fully structured release workflow with clear phases and safeguards
* Added preview-first releases with explicit confirmation before execution
* Automatic handling of *latest* tag when a release is the newest version
* Optional branch closing after successful releases with interactive confirmation
* Improved safety by syncing with remote before any changes
* Clear separation of concerns (workflow, git handling, prompts, versioning)
## [1.1.0] - 2025-12-12
* Added *branch drop* for destructive branch deletion and introduced *--force/-f* flags for branch close and branch drop to skip confirmation prompts.
## [1.0.0] - 2025-12-11
* **1.0.0 Official Stable Release 🎉**
*First stable release of PKGMGR, the multi-distro development and package workflow manager.*
---
**Key Features**
**Core Functionality**
* Manage many repositories with one CLI: `clone`, `update`, `install`, `list`, `path`, `config`
* Proxy wrappers for Git, Docker/Compose and Make
* Multi-repo execution with safe *preview mode*
* Mirror management: `mirror list/diff/merge/setup`
**Releases & Versioning**
* Automated SemVer bumps, tagging and changelog generation
* Supports PKGBUILD, Debian, RPM, pyproject.toml, flake.nix
**Developer Tools**
* Open repositories in VS Code, file manager or terminal
* Unified workflows across all major Linux distros
**Nix Integration**
* Cross-distro reproducible builds via Nix flakes
* CI-tested across all supported environments
---
**Summary**
PKGMGR 1.0.0 unifies repository management, build tooling, release automation and reproducible multi-distro workflows into one cohesive CLI tool.
*This is the first official stable release.*
## [0.10.2] - 2025-12-11 ## [0.10.2] - 2025-12-11
* * Stable tag now updates only when a new highest version is released. * * Stable tag now updates only when a new highest version is released.

View File

@@ -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
echo "Building and installing package-manager via make install..."; \ RUN set -euo pipefail; \
make install; \ echo "Building and installing package-manager via make install..."; \
rm -rf /build make install; \
cd /; rm -rf /build
# Entry point
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
# ------------------------------------------------------------
# Runtime working directory and dev entrypoint
# ------------------------------------------------------------
WORKDIR /src WORKDIR /src
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"] ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
CMD ["pkgmgr", "--help"] CMD ["pkgmgr", "--help"]

View File

@@ -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
# ------------------------------------------------------------ # ------------------------------------------------------------

180
README.md
View File

@@ -1,70 +1,188 @@
# Package Manager🤖📦 # Package Manager 🤖📦
![PKGMGR Banner](assets/banner.jpg)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![GitHub license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![GitHub license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![GitHub repo size](https://img.shields.io/github/repo-size/kevinveenbirkenbach/package-manager)](https://github.com/kevinveenbirkenbach/package-manager) [![GitHub repo size](https://img.shields.io/github/repo-size/kevinveenbirkenbach/package-manager)](https://github.com/kevinveenbirkenbach/package-manager)
*Kevins's* Package Manager is a configurable Python tool designed to manage multiple repositories via Bash. It automates common Git operations such as clone, pull, push, status, and more. Additionally, it handles the creation of executable wrappers and alias links for your repositories. **Kevin's Package Manager (PKGMGR)** is a *multi-distro* package manager and workflow orchestrator.
It helps you **develop, package, release and manage projects across multiple Linux-based
operating systems** (Arch, Debian, Ubuntu, Fedora, CentOS, …).
PKGMGR is implemented in **Python** and uses **Nix (flakes)** as a foundation for
distribution-independent builds and tooling. On top of that it provides a rich
CLI that proxies common developer tools (Git, Docker, Make, …) and glues them
together into repeatable development workflows.
---
## Why PKGMGR? 🧠
Traditional distro package managers like `apt`, `pacman` or `dnf` focus on a
single operating system. PKGMGR instead focuses on **your repositories and
development lifecycle**:
* one configuration for all your repos,
* one CLI to interact with them,
* one Nix-based layer to keep tooling reproducible across distros.
You keep using your native package manager where it makes sense PKGMGR
coordinates the *development and release flow* around it.
---
## Features 🚀 ## Features 🚀
- **Installation & Setup:** ### Multi-distro development & packaging
Create executable wrappers with auto-detected commands (e.g. `main.sh` or `main.py`).
* Manage **many repositories at once** from a single `config/config.yaml`.
- **Git Operations:** * Drive full **release pipelines** across Linux distributions using:
Easily perform `git pull`, `push`, `status`, `commit`, `diff`, `add`, `show`, and `checkout` with extra parameters passed through.
* Nix flakes (`flake.nix`)
- **Configuration Management:** * PyPI style builds (`pyproject.toml`)
Manage repository configurations via a default file (`config/defaults.yaml`) and a user-specific file (`config/config.yaml`). Initialize, add, delete, or ignore entries using subcommands. * OS packages (PKGBUILD, Debian control/changelog, RPM spec)
* Ansible Galaxy metadata and more.
- **Path & Listing:**
Display repository paths or list all configured packages with their details. ### Rich CLI for daily work
- **Custom Aliases:** All commands are exposed via the `pkgmgr` CLI and are available on every distro:
Generate and manage custom aliases for easy command invocation.
* **Repository management**
* `clone`, `update`, `install`, `delete`, `deinstall`, `path`, `list`, `config`
* **Git proxies**
* `pull`, `push`, `status`, `diff`, `add`, `show`, `checkout`,
`reset`, `revert`, `rebase`, `commit`, `branch`
* **Docker & Compose orchestration**
* `build`, `up`, `down`, `exec`, `ps`, `start`, `stop`, `restart`
* **Release toolchain**
* `version`, `release`, `changelog`, `make`
* **Mirror & workflow helpers**
* `mirror` (list/diff/merge/setup), `shell`, `terminal`, `code`, `explore`
Many of these commands support `--preview` mode so you can inspect the
underlying Git or Docker calls without executing them.
### Full development workflows
PKGMGR is not just a helper around Git commands. Combined with its release and
versioning features it can drive **end-to-end workflows**:
1. Clone and mirror repositories.
2. Run tests and builds through `make` or Nix.
3. Bump versions, update changelogs and tags.
4. Build distro-specific packages.
5. Keep all mirrors and working copies in sync.
The extensive E2E tests (`tests/e2e/`) and GitHub Actions workflows (including
“virgin user” and “virgin root” Arch tests) validate these flows across
different Linux environments.
---
## Architecture & Setup Map 🗺️ ## Architecture & Setup Map 🗺️
The following diagram provides a full overview of PKGMGRs package structure, The following diagram gives a full overview of:
installation layers, and setup controller flow:
* PKGMGRs package structure,
* the layered installers (OS, foundation, Python, Makefile),
* and the setup controller that decides which layer to use on a given system.
![PKGMGR Architecture](assets/map.png) ![PKGMGR Architecture](assets/map.png)
**Diagram status:** *Stand: 11. Dezember 2025* **Diagram status:** 12 December 2025
**Always-up-to-date version:** https://s.veen.world/pkgmgrmp **Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
---
## Installation ⚙️ ## Installation ⚙️
Clone the repository and ensure your `~/.local/bin` is in your system PATH: ### 1. Get the latest stable version
For a stable setup, use the **latest tagged release** (the tag pointed to by
`latest`):
```bash ```bash
git clone https://github.com/kevinveenbirkenbach/package-manager.git git clone https://github.com/kevinveenbirkenbach/package-manager.git
cd package-manager cd package-manager
# Optional but recommended: checkout the latest stable tag
git fetch --tags
git checkout "$(git describe --tags --abbrev=0)"
``` ```
Install make and pip if not installed yet: ### 2. Install via Make
The project ships with a Makefile that encapsulates the typical installation
flow. On most systems you only need:
```bash ```bash
pacman -S make python-pip # Ensure make, Python and pip are installed via your distro package manager
# (e.g. pacman -S make python python-pip, apt install make python3-pip, ...)
make install
``` ```
Then, run the following command to set up the project: This will:
* create or reuse a Python virtual environment,
* install PKGMGR (and its Python dependencies) into that environment,
* expose the `pkgmgr` executable on your PATH (usually via `~/.local/bin`),
* prepare Nix-based integration where available so PKGMGR can build and manage
packages distribution-independently.
For development use, you can also run:
```bash ```bash
make setup make setup
``` ```
The `make setup` command will: which prepares the environment and leaves you with a fully wired development
- Make `main.py` executable. workspace (including Nix, tests and scripts).
- Install required packages from `requirements.txt`.
- Execute `python main.py install` to complete the installation. ---
## Usage 🧰
After installation, the main entry point is:
```bash
pkgmgr --help
```
This prints a list of all available subcommands, for example:
* `pkgmgr list --all` show all repositories in the config
* `pkgmgr update --all --clone-mode https` update every repository
* `pkgmgr release patch --preview` simulate a patch release
* `pkgmgr version --all` show version information for all repositories
* `pkgmgr mirror setup --preview --all` prepare Git mirrors (no changes in preview)
* `pkgmgr make install --preview pkgmgr` preview make install for the pkgmgr repo
The help for each command is available via:
```bash
pkgmgr <command> --help
```
---
## License 📄 ## License 📄
This project is licensed under the MIT License. This project is licensed under the MIT License.
See the [LICENSE](LICENSE) file for details.
---
## Author 👤 ## Author 👤
Kevin Veen-Birkenbach Kevin Veen-Birkenbach
[https://www.veen.world](https://www.veen.world) [https://www.veen.world](https://www.veen.world)

View File

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

BIN
assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

27
flake.lock generated
View File

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

View File

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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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."

View File

@@ -7,10 +7,10 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "package-manager" name = "package-manager"
version = "0.10.2" 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 = [

View File

@@ -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" \
.

View File

@@ -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" \
.

View File

@@ -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
View 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[@]}"

View File

@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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."
} }

View File

@@ -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

View File

@@ -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."

View File

@@ -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."

View File

@@ -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/*

View File

@@ -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."
# exit 0
# Developer / system setup entrypoint.
#
# Responsibilities:
# - If inside a Nix shell (IN_NIX_SHELL=1):
# * Skip venv creation and dependency installation
# * Run `python3 main.py install`
# - If running as root (EUID=0):
# * Run system-level installer (run-package.sh)
# - Otherwise (normal user):
# * Create ~/.venvs/pkgmgr virtual environment if missing
# * Install Python dependencies into that venv
# * Append auto-activation to ~/.bashrc and ~/.zshrc
# * Run `main.py install` using the venv Python
# ------------------------------------------------------------
echo "[installation/main] Starting setup..."
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${PROJECT_ROOT}"
VENV_DIR="${HOME}/.venvs/pkgmgr"
RC_LINE='if [ -d "${HOME}/.venvs/pkgmgr" ]; then . "${HOME}/.venvs/pkgmgr/bin/activate"; if [ -n "${PS1:-}" ]; then echo "Global Python virtual environment '\''~/.venvs/pkgmgr'\'' activated."; fi; fi'
# ------------------------------------------------------------
# 1) Nix shell mode: do not touch venv, only run main.py install
# ------------------------------------------------------------
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
echo "[installation/main] Nix shell detected (IN_NIX_SHELL=1)."
echo "[installation/main] Skipping virtualenv creation and dependency installation."
echo "[installation/main] Running main.py install via system python3..."
python3 main.py install
echo "[installation/main] Setup finished (Nix mode)."
exit 0
fi 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."

View File

@@ -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}"

View File

@@ -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

View File

@@ -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
View 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
View 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."

View File

@@ -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
View 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."
'

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View 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

View 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

View 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

View File

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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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]:
""" """

View File

@@ -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:

View File

@@ -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

View 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

View File

@@ -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"]

View File

@@ -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")

View 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")

View 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,
)

View File

@@ -18,52 +18,17 @@ USER_CONFIG_PATH = os.path.expanduser("~/.config/pkgmgr/config.yaml")
DESCRIPTION_TEXT = """\ DESCRIPTION_TEXT = """\
\033[1;32mPackage Manager 🤖📦\033[0m \033[1;32mPackage Manager 🤖📦\033[0m
\033[3mKevin's Package Manager is a multi-repository, multi-package, and multi-format \033[3mKevin's multi-distro package and workflow manager.\033[0m
development tool crafted by and designed for:\033[0m \033[1;34mKevin Veen-Birkenbach\033[0m \033[4mhttps://www.veen.world/\033[0m
\033[1;34mKevin Veen-Birkenbach\033[0m
\033[4mhttps://www.veen.world/\033[0m
\033[1mOverview:\033[0m Built in \033[1;33mPython\033[0m on top of \033[1;33mNix flakes\033[0m to manage many
A powerful toolchain that unifies and automates workflows across heterogeneous repositories and packaging formats (pyproject.toml, flake.nix,
project ecosystems. pkgmgr is not only a package manager — it is a full PKGBUILD, debian, Ansible, …) with one CLI.
developer-oriented orchestration tool.
It automatically detects, merges, and processes metadata from multiple For details on any command, run:
dependency formats, including: \033[1mpkgmgr <command> --help\033[0m
\033[1;33mPython:\033[0m pyproject.toml, requirements.txt
\033[1;33mNix:\033[0m flake.nix
\033[1;33mArch Linux:\033[0m PKGBUILD
\033[1;33mAnsible:\033[0m requirements.yml
This allows pkgmgr to perform installation, updates, verification, dependency
resolution, and synchronization across complex multi-repo environments — with a
single unified command-line interface.
\033[1mDeveloper Tools:\033[0m
pkgmgr includes an integrated toolbox to enhance daily development workflows:
\033[1;33mVS Code integration:\033[0m Auto-generate and open multi-repo workspaces
\033[1;33mTerminal integration:\033[0m Open repositories in new GNOME Terminal tabs
\033[1;33mExplorer integration:\033[0m Open repositories in your file manager
\033[1;33mRelease automation:\033[0m Version bumping, changelog updates, and tagging
\033[1;33mBatch operations:\033[0m Execute shell commands across multiple repositories
\033[1;33mGit/Docker/Make wrappers:\033[0m Unified command proxying for many tools
\033[1mCapabilities:\033[0m
• Clone, pull, verify, update, and manage many repositories at once
• Resolve dependencies across languages and ecosystems
• Standardize install/update workflows
• Create symbolic executable wrappers for any project
• Merge configuration from default + user config layers
Use pkgmgr as both a robust package management framework and a versatile
development orchestration tool.
For detailed help on each command, use:
\033[1mpkgmgr <command> --help\033[0m
""" """
def main() -> None: def main() -> None:
""" """
Entry point for the pkgmgr CLI. Entry point for the pkgmgr CLI.

View File

@@ -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

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
import os import os
import sys import sys

View File

@@ -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.

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
import os import os
import sys import sys

View File

@@ -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",
)

View File

@@ -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

View File

@@ -1,3 +1,4 @@
from typing import Optional
# pkgmgr/run_command.py # pkgmgr/run_command.py
import subprocess import subprocess
import sys import sys

View File

@@ -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.

View File

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

View File

@@ -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: try:
sys.argv = [ print(f"\n[TEST] Running ({label}): {cmd_repr}")
"pkgmgr", subprocess.run(
"update", cmd,
"--all", check=True,
"--clone-mode", cwd=os.getcwd(),
"https", env=env,
"--no-verification", text=True,
] )
except subprocess.CalledProcessError as exc:
print(f"\n[TEST] Command failed ({label})")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exc.returncode}")
try: nix_profile_list_debug(f"ON FAILURE ({label})")
# Execute main.py as if it was called from CLI.
# This will run the full update pipeline inside the container.
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
# Convert SystemExit into a more helpful assertion with debug output.
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print("\n[TEST] pkgmgr update --all failed with SystemExit") raise AssertionError(
print(f"[TEST] Command : {cmd_repr}") f"({label}) {cmd_repr!r} failed with exit code {exc.returncode}. "
print(f"[TEST] Exit code: {exit_code}") "Scroll up to see the full pkgmgr/nix output inside the container."
) from exc
# Additional Nix profile debug on failure (useful if any update def _common_setup(self) -> None:
# step interacts with Nix-based tooling).
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exit_code}. "
"Scroll up to see the full pkgmgr/make output inside the container."
) from exc
finally:
sys.argv = original_argv
def test_update_all_repositories_https(self) -> None:
"""
Run: pkgmgr update --all --clone-mode https --no-verification
This will perform real git update operations inside the container.
The test succeeds if no exception is raised and `pkgmgr --help`
works in a fresh interactive bash session afterwards.
"""
# 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View 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()

View 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()

View File

@@ -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()

View 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()

View File

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

View File

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

View File

@@ -22,6 +22,10 @@ class TestCliBranch(unittest.TestCase):
user_config_path="/tmp/config.yaml", 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:
""" """