Compare commits

...

16 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
e930b422e5 Release version 0.7.9 2025-12-09 21:19:13 +01:00
Kevin Veen-Birkenbach
0833d04376 Improve branch helpers with main/master base resolution
- Update pkgmgr.actions.branch.open_branch() to resolve the base branch
  via _resolve_base_branch(), preferring 'main' and falling back to
  'master' when the preferred branch does not exist.
- Adjust the open_branch logic to:
  - fetch from origin
  - checkout the resolved base branch
  - pull the resolved base branch
  - create the feature branch
  - push the new branch with upstream tracking
- Add and refine unit tests in tests/unit/pkgmgr/actions/test_branch.py
  to cover:
  - normal branch creation with explicit name and default base
  - interactive name prompting when no name is provided
  - error handling when fetch fails after successful base resolution
  - fallback to 'master' when 'main' is missing.
- Clean up and clarify docstrings and comments for open_branch(),
  close_branch(), and _resolve_base_branch(), and fix the module header
  comment to match the new package path.

This fixes branch opening in repositories that still use 'master' as
their primary branch while keeping the default behavior for 'main'.

https://chatgpt.com/share/6938838f-7aac-800f-b130-924e07ef48b9
2025-12-09 21:16:10 +01:00
Kevin Veen-Birkenbach
55f36d76ec Merge branch 'fix/file-error' 2025-12-09 21:09:48 +01:00
Kevin Veen-Birkenbach
6a838ee84f Release version 0.7.8 2025-12-09 21:03:24 +01:00
Kevin Veen-Birkenbach
4285bf4a54 Fix: release now skips missing pyproject.toml without failing
- Updated update_pyproject_version() to gracefully skip missing or unreadable pyproject.toml
- Added corresponding unit test ensuring missing file triggers no exception and no file creation
- Updated test wording for spec changelog section
- Ref: adjustments discussed in ChatGPT conversation (2025-12-09) - https://chatgpt.com/share/69388024-93e4-800f-a09f-bf78a6b9a53f
2025-12-09 21:02:01 +01:00
Kevin Veen-Birkenbach
640b1042c2 git commit -m "Harden installers for Nix, OS packages and Docker CA handling
- NixFlakeInstaller:
  - Skip when running inside a Nix dev shell (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_NIX_FLAKE_INSTALLER kill-switch for CI/debugging.
  - Ensure run() respects supports() and handles preview/allow_failure cleanly.

- DebianControlInstaller:
  - Introduce _privileged_prefix() to handle sudo vs. root vs. no elevation.
  - Avoid hard-coded sudo usage and degrade gracefully when neither sudo nor
    root is available.
  - Improve messaging around build-dep and .deb installation.

- RpmSpecInstaller:
  - Prepare rpmbuild tree and source tarball in ~/rpmbuild/SOURCES based on
    Name/Version from the spec file.
  - Reuse a helper to resolve the rpmbuild topdir.
  - Install built RPMs via dnf/yum when available, falling back to rpm -Uvh
    to avoid file conflicts during upgrades.

- PythonInstaller:
  - Skip pip-based installation inside Nix dev shells (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_PYTHON_INSTALLER kill-switch.
  - Make pip command resolution explicit and overridable via PKGMGR_PIP.
  - Type-hint supports() and run() with RepoContext/InstallContext.

- Docker entrypoint:
  - Add robust CA bundle detection for Nix, Git, Python requests and curl.
  - Export NIX_SSL_CERT_FILE, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and
    GIT_SSL_CAINFO from a single detected CA path.
  - Improve logging and section comments in the entrypoint script."

https://chatgpt.com/share/69387df8-bda0-800f-a053-aa9e2999dc84
2025-12-09 20:52:07 +01:00
Kevin Veen-Birkenbach
9357c4632e Release version 0.7.7 2025-12-09 17:54:41 +01:00
Kevin Veen-Birkenbach
ca5d0d22f3 feat(test): make unittest pattern configurable and pass TEST_PATTERN into containers
This update introduces a configurable TEST_PATTERN variable in the Makefile,
allowing selective execution of unit, integration, and E2E tests without
modifying scripts.

Key changes:
- Add TEST_PATTERN (default: test_*.py) to Makefile and export it.
- Inject TEST_PATTERN into all test containers via `-e TEST_PATTERN=...`.
- Update test-unit.sh, test-integration.sh, and test-e2e.sh to use
  `-p "$TEST_PATTERN"` instead of a hardcoded pattern.
- Ensure flexible test selection via:
      make test-e2e TEST_PATTERN=test_install_pkgmgr_shallow.py

This enables fast debugging, selective test runs, and better developer
experience while keeping full compatibility with CI defaults.

https://chatgpt.com/share/69385400-2f14-800f-b093-bb03c8ef9c7f
2025-12-09 17:53:10 +01:00
Kevin Veen-Birkenbach
3875338fb7 Release version 0.7.6 2025-12-09 17:14:22 +01:00
Kevin Veen-Birkenbach
196f55c58e feat(repository/pull): improve verification logic and add full unit test suite
This commit enhances the behaviour of pull_with_verification() and adds a
comprehensive unit test suite covering all control flows.

Changes:
- Added `preview` parameter to fully disable interaction and execution.
- Improved verification logic:
  * Prompt only when not in preview, verification is enabled,
    verification info exists, and verification failed.
  * Skip prompts entirely when --no-verification is set.
- More explicit construction of `git pull` command with optional extra args.
- Improved messaging and formatting for clarity.
- Ensured directory existence is checked before any verification logic.
- Added detailed comments explaining logic and conditions.

Tests:
- New file tests/unit/pkgmgr/actions/repos/test_pull_with_verification.py
- Covers:
  * Preview mode (no input, no subprocess)
  * Verification failure – user rejects
  * Verification failure – user accepts
  * Verification success – immediate git call
  * Missing repository directory – skip silently
  * --no-verification flag bypasses prompts
  * Command formatting with extra args
- Uses systematic mocking for identifier, repo-dir, verify_repository(),
  subprocess.run(), and user input.

This significantly strengthens correctness, UX, and test coverage of the
repository pull workflow.

https://chatgpt.com/share/69384aaa-0c80-800f-b4b4-64e6fbdebd3b
2025-12-09 17:12:23 +01:00
Kevin Veen-Birkenbach
9a149715f6 Release version 0.7.5 2025-12-09 16:45:45 +01:00
Kevin Veen-Birkenbach
bf40533469 fix(init-nix): ensure /nix is always owned by nix:nixbld in container root mode
In GitHub's Fedora-based CI containers the directory /nix may already exist
(e.g. from the base image or a previous build layer) and is often owned by
root:root. In this situation the Nix single-user installer aborts with:

    "directory /nix exists, but is not writable by you"

This caused the container build to fail during `init-nix.sh`, leaving no
working `nix` binary on PATH. As a result, the runtime wrapper
(pkmgr-wrapper.sh) reported:

    "[pkgmgr-wrapper] ERROR: 'nix' binary not found on PATH."

Local runs did not show the issue because a previous installation had already
created /nix with correct ownership.

This commit makes container-mode Nix initialization fully idempotent:

  • If /nix does not exist → create it with owner nix:nixbld (existing logic).
  • If /nix exists but has wrong owner/group → forcibly chown -R nix:nixbld.
  • A warning is emitted if /nix remains non-writable after correction.

This guarantees that the Nix installer always has writable access to /nix
and prevents the installer from aborting in CI. As a result, `pkgmgr --help`
works again inside Fedora CI containers.

https://chatgpt.com/share/69384149-9dc8-800f-8148-55817ece8e21
2025-12-09 16:33:22 +01:00
Kevin Veen-Birkenbach
7bc7259988 Release version 0.7.4 2025-12-09 16:22:03 +01:00
Kevin Veen-Birkenbach
66b96ac3a5 Refactor CI workflows and Makefile to unify container builds and simplify test execution
This commit updates all GitHub Actions workflows and the Makefile to ensure
consistent behavior across unit, integration, end-to-end, and OS-container
tests.

Changes include:

CI Workflows:
  - Rename workflows for clearer, more professional naming:
        * "Test Distribution Containers" → "Test OS Containers"
        * "Test package-manager (e2e)" → "Test End-To-End"
        * "Test package-manager (unit)" → "Test Units"
        * "Test package-manager (integration)" → "Test Code Integration"
  - Remove explicit build steps from workflows; container creation is now
    delegated to the Makefile via build-missing.
  - Restrict test jobs to only build the Arch test container by setting:
        DISTROS="arch"

Makefile:
  - Add build-missing as a dependency to all test targets:
        test-unit, test-integration, test-e2e, test-container
  - Remove redundant build-missing call from the combined 'test' target,
    since Make now ensures build-missing runs exactly once per invocation.
  - Preserve existing target structure while ensuring container images are
    built automatically on demand.

This makes the CI pipeline faster, more predictable, and removes duplicated
container build logic. All tests now use the same unified mechanism for
building missing images.
2025-12-09 16:18:15 +01:00
Kevin Veen-Birkenbach
f974e0b14a Release version 0.7.3 2025-12-09 16:08:34 +01:00
Kevin Veen-Birkenbach
de8c3f768d feat(repository): integrate ignore filtering into selection pipeline + add unit tests
This commit introduces proper handling of the `ignore: true` flag in the
repository selection mechanism and adds comprehensive unit tests for both
`ignored.py` and `selected.py`.

- `get_selected_repos()` now filters ignored repositories in all implicit
  selection modes:
    • filter-only mode (string/category/tag)
    • `--all` mode
    • CWD-based selection

- Explicit identifiers (e.g. `pkgmgr install ignored-repo`) **bypass**
  ignore filtering, so the user can still operate directly on ignored
  repositories if they ask for them explicitly.

- Added `_maybe_filter_ignored()` helper to handle logic cleanly and allow
  future extension (e.g. integrating a CLI flag `--include-ignored`).

Under `tests/unit/pkgmgr/core/repository`:

1. **test_ignored.py**
   • Ensures `filter_ignored()` removes repos with `ignore: true`
   • Ensures empty lists are handled correctly

2. **test_selected.py**
   Comprehensive coverage of the selection logic:
   • Explicit identifiers bypass ignore filtering
   • Filter-only mode excludes ignored repos unless `include_ignored=True`
   • `--all` mode excludes ignored repos unless explicitly overridden
   • CWD-based detection filters ignored repos unless explicitly overridden

Before this change, ignored repositories still appeared in `pkgmgr list`,
`pkgmgr status`, and other commands using `get_selected_repos()`.
This was unintuitive and broke the expected semantics of the `ignore`
attribute.
The new logic ensures ignored repositories are truly invisible unless
explicitly requested.

https://chatgpt.com/share/69383b41-50a0-800f-a2b9-c680cd96d9e9
2025-12-09 16:07:39 +01:00
31 changed files with 1321 additions and 195 deletions

View File

@@ -1,4 +1,4 @@
name: Test Distribution Containers
name: Test OS Containers
on:
push:

View File

@@ -1,4 +1,4 @@
name: Test package-manager (e2e)
name: Test End-To-End
on:
push:

View File

@@ -1,4 +1,4 @@
name: Test package-manager (integration)
name: Test Code Integration
on:
push:
@@ -21,9 +21,5 @@ jobs:
- name: Show Docker version
run: docker version
# Build Arch test image (same as used in test-unit and test-e2e)
- name: Build test images
run: make build
- name: Run integration tests via make (Arch container)
run: make test-integration
run: make test-integration DISTROS="arch"

View File

@@ -1,4 +1,4 @@
name: Test package-manager (unit)
name: Test Units
on:
push:
@@ -22,4 +22,4 @@ jobs:
run: docker version
- name: Run unit tests via make (Arch container)
run: make test-unit
run: make test-unit DISTROS="arch"

View File

@@ -1,3 +1,38 @@
## [0.7.9] - 2025-12-09
* 'main' and 'master' are now both accepted as branches for branch close merge
## [0.7.8] - 2025-12-09
* Missing pyproject.toml doesn't lead to an error during release
## [0.7.7] - 2025-12-09
* Added TEST_PATTERN parameter to execute dedicated tests
## [0.7.6] - 2025-12-09
* Fixed pull --preview bug in e2e test
## [0.7.5] - 2025-12-09
* Fixed wrong directory permissions for nix
## [0.7.4] - 2025-12-09
* Fixed missing build in test workflow -> Tests pass now
## [0.7.3] - 2025-12-09
* Fixed bug: Ignored packages are now ignored
## [0.7.2] - 2025-12-09
* Implemented Changelog Support for Fedora and Debian

View File

@@ -12,7 +12,7 @@ NIX_CACHE_VOLUME := pkgmgr_nix_cache
# Distro list and base images
# (kept for documentation/reference; actual build logic is in scripts/build)
# ------------------------------------------------------------
DISTROS := arch debian ubuntu fedora centos
DISTROS := arch debian ubuntu fedora centos
BASE_IMAGE_ARCH := archlinux:latest
BASE_IMAGE_DEBIAN := debian:stable-slim
BASE_IMAGE_UBUNTU := ubuntu:latest
@@ -27,6 +27,10 @@ export BASE_IMAGE_UBUNTU
export BASE_IMAGE_FEDORA
export BASE_IMAGE_CENTOS
# PYthon Unittest Pattern
TEST_PATTERN := test_*.py
export TEST_PATTERN
# ------------------------------------------------------------
# PKGMGR setup (developer wrapper -> scripts/installation/main.sh)
# ------------------------------------------------------------
@@ -46,16 +50,16 @@ build:
# Test targets (delegated to scripts/test)
# ------------------------------------------------------------
test-unit:
test-unit: build-missing
@bash scripts/test/test-unit.sh
test-integration:
test-integration: build-missing
@bash scripts/test/test-integration.sh
test-e2e:
test-e2e: build-missing
@bash scripts/test/test-e2e.sh
test-container:
test-container: build-missing
@bash scripts/test/test-container.sh
# ------------------------------------------------------------
@@ -65,7 +69,7 @@ build-missing:
@bash scripts/build/build-image-missing.sh
# Combined test target for local + CI (unit + e2e + integration)
test: build-missing test-container test-unit test-e2e test-integration
test: test-container test-unit test-e2e test-integration
# ------------------------------------------------------------
# System install (native packages, calls scripts/installation/run-package.sh)

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=0.7.2
pkgver=0.7.9
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

42
debian/changelog vendored
View File

@@ -1,3 +1,45 @@
package-manager (0.7.9-1) unstable; urgency=medium
* 'main' and 'master' are now both accepted as branches for branch close merge
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:19:13 +0100
package-manager (0.7.8-1) unstable; urgency=medium
* Missing pyproject.toml doesn't lead to an error during release
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:03:24 +0100
package-manager (0.7.7-1) unstable; urgency=medium
* Added TEST_PATTERN parameter to execute dedicated tests
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:54:38 +0100
package-manager (0.7.6-1) unstable; urgency=medium
* Fixed pull --preview bug in e2e test
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:14:19 +0100
package-manager (0.7.5-1) unstable; urgency=medium
* Fixed wrong directory permissions for nix
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:45:42 +0100
package-manager (0.7.4-1) unstable; urgency=medium
* Fixed missing build in test workflow -> Tests pass now
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:22:00 +0100
package-manager (0.7.3-1) unstable; urgency=medium
* Fixed bug: Ignored packages are now ignored
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:08:31 +0100
package-manager (0.7.2-1) unstable; urgency=medium
* Implemented Changelog Support for Fedora and Debian

View File

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

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.7.2
Version: 0.7.9
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -77,6 +77,27 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.9-1
- 'main' and 'master' are now both accepted as branches for branch close merge
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.8-1
- Missing pyproject.toml doesn't lead to an error during release
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.7-1
- Added TEST_PATTERN parameter to execute dedicated tests
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.6-1
- Fixed pull --preview bug in e2e test
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.5-1
- Fixed wrong directory permissions for nix
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.4-1
- Fixed missing build in test workflow -> Tests pass now
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.3-1
- Fixed bug: Ignored packages are now ignored
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.2-1
- Implemented Changelog Support for Fedora and Debian

View File

@@ -1,4 +1,4 @@
# pkgmgr/branch_commands.py
# pkgmgr/actions/branch/__init__.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -16,30 +16,43 @@ from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
# ---------------------------------------------------------------------------
# Branch creation (open)
# ---------------------------------------------------------------------------
def open_branch(
name: Optional[str],
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
) -> None:
"""
Create and push a new feature branch on top of `base_branch`.
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 <base_branch>
3) git pull origin <base_branch>
4) git checkout -b <name>
5) git push -u origin <name>
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 on stdin.
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)
@@ -50,18 +63,18 @@ def open_branch(
# 2) Checkout base branch
try:
run_git(["checkout", base_branch], cwd=cwd)
run_git(["checkout", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {base_branch!r}: {exc}"
f"Failed to checkout base branch {resolved_base!r}: {exc}"
) from exc
# 3) Pull latest changes on base
# 3) Pull latest changes for base branch
try:
run_git(["pull", "origin", base_branch], cwd=cwd)
run_git(["pull", "origin", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {base_branch!r}: {exc}"
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
) from exc
# 4) Create new branch
@@ -69,10 +82,10 @@ def open_branch(
run_git(["checkout", "-b", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to create new branch {name!r} from base {base_branch!r}: {exc}"
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
) from exc
# 5) Push and set upstream
# 5) Push new branch to origin
try:
run_git(["push", "-u", "origin", name], cwd=cwd)
except GitError as exc:
@@ -81,15 +94,21 @@ def open_branch(
) 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 for merging.
Resolve the base branch to use.
Try `preferred` first (default: main),
fall back to `fallback` (default: master).
Try `preferred` (default: main) first, then `fallback` (default: master).
Raise RuntimeError if neither exists.
"""
for candidate in (preferred, fallback):
@@ -104,6 +123,10 @@ def _resolve_base_branch(
)
# ---------------------------------------------------------------------------
# Branch closing (merge + deletion)
# ---------------------------------------------------------------------------
def close_branch(
name: Optional[str],
base_branch: str = "main",
@@ -111,23 +134,22 @@ def close_branch(
cwd: str = ".",
) -> None:
"""
Merge a feature branch into the main/master branch and optionally delete it.
Merge a feature branch into the base branch and delete it afterwards.
Steps:
1) Determine branch name (argument or current branch)
2) Resolve base branch (prefers `base_branch`, falls back to `fallback_base`)
3) Ask for confirmation (y/N)
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 and on origin
If the user does not confirm with 'y', the operation is aborted.
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 to close
# 1) Determine which branch should be closed
if not name:
try:
name = get_current_branch(cwd=cwd)
@@ -137,7 +159,7 @@ def close_branch(
if not name:
raise RuntimeError("Branch name must not be empty.")
# 2) Resolve base branch (main/master)
# 2) Resolve base branch
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
@@ -146,7 +168,7 @@ def close_branch(
"Please specify a feature branch."
)
# 3) Confirmation prompt
# 3) Ask user for confirmation
prompt = (
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? "
"(y/N): "
@@ -164,7 +186,7 @@ def close_branch(
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc
# 5) Checkout base branch
# 5) Checkout base
try:
run_git(["checkout", target_base], cwd=cwd)
except GitError as exc:
@@ -172,7 +194,7 @@ def close_branch(
f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc
# 6) Pull latest base
# 6) Pull latest base state
try:
run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc:
@@ -180,7 +202,7 @@ def close_branch(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc
# 7) Merge feature branch into base
# 7) Merge the feature branch
try:
run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc:
@@ -193,22 +215,21 @@ def close_branch(
run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push base branch {target_base!r} to origin after merge: {exc}"
f"Failed to push base branch {target_base!r} after merge: {exc}"
) from exc
# 9) Delete feature branch locally
# 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} after merge: {exc}"
f"Failed to delete local branch {name!r}: {exc}"
) from exc
# 10) Delete feature branch on origin (best effort)
# 10) Delete branch on origin (best effort)
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
# Remote delete is nice-to-have; surface as RuntimeError for clarity.
raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -85,7 +85,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
# File update helpers (pyproject + extra packaging + changelog)
# ---------------------------------------------------------------------------
def update_pyproject_version(
pyproject_path: str,
new_version: str,
@@ -99,13 +98,25 @@ def update_pyproject_version(
version = "X.Y.Z"
and replaces the version part with the given new_version string.
If the file does not exist, it is skipped without failing the release.
"""
if not os.path.exists(pyproject_path):
print(
f"[INFO] pyproject.toml not found at: {pyproject_path}, "
"skipping version update."
)
return
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"[ERROR] pyproject.toml not found at: {pyproject_path}")
sys.exit(1)
except OSError as exc:
print(
f"[WARN] Could not read pyproject.toml at {pyproject_path}: {exc}. "
"Skipping version update."
)
return
pattern = r'^(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(

View File

@@ -13,6 +13,13 @@ Behavior:
* Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`.
- Failure installing `pkgmgr` is treated as fatal.
- Failure installing `default` is logged as an error/warning but does not abort.
Special handling for dev shells / CI:
- If IN_NIX_SHELL is set (e.g. inside `nix develop`), the installer is
disabled. In that environment the flake outputs are already provided
by the dev shell and we must not touch the user profile.
- If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
"""
import os
@@ -36,14 +43,45 @@ class NixFlakeInstaller(BaseInstaller):
FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager"
def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the flake outputs are already available, and touching the user
profile (nix profile install/remove) is undesirable.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool:
"""
Only support repositories that:
- Have a flake.nix
- Are NOT inside a Nix dev shell (IN_NIX_SHELL unset),
- Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1,
- Have a flake.nix,
- And have the `nix` command available.
"""
# 1) Skip when running inside a dev shell flake is already active.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping NixFlakeInstaller. "
"Flake outputs are provided by the development shell."
)
return False
# 2) Optional global kill-switch for CI or debugging.
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 "
"NixFlakeInstaller is disabled."
)
return False
# 3) Nix must be available.
if shutil.which("nix") is None:
return False
# 4) flake.nix must exist in the repository.
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
return os.path.exists(flake_path)
@@ -76,6 +114,14 @@ class NixFlakeInstaller(BaseInstaller):
Any failure installing `pkgmgr` is treated as fatal (SystemExit).
A failure installing `default` is logged but does not abort.
"""
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in run(); "
"skipping Nix flake profile installation."
)
return
# Reuse supports() to keep logic in one place
if not self.supports(ctx): # type: ignore[arg-type]
return
@@ -91,7 +137,12 @@ class NixFlakeInstaller(BaseInstaller):
try:
# For 'default' we don't want the process to exit on error
allow_failure = output == "default"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure)
run_command(
cmd,
cwd=ctx.repo_dir,
preview=ctx.preview,
allow_failure=allow_failure,
)
print(f"Nix flake output '{output}' successfully installed.")
except SystemExit as e:
print(f"[Error] Failed to install Nix flake output '{output}': {e}")

View File

@@ -17,7 +17,6 @@ apt/dpkg tooling are available.
import glob
import os
import shutil
from typing import List
from pkgmgr.actions.repository.install.context import RepoContext
@@ -68,6 +67,32 @@ class DebianControlInstaller(BaseInstaller):
pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern))
def _privileged_prefix(self) -> str | None:
"""
Determine how to run privileged commands:
- If 'sudo' is available, return 'sudo '.
- If we are running as root (e.g. inside CI/container), return ''.
- Otherwise, return None, meaning we cannot safely elevate.
Callers are responsible for handling the None case (usually by
warning and skipping automatic installation).
"""
sudo_path = shutil.which("sudo")
is_root = False
try:
is_root = os.geteuid() == 0
except AttributeError: # pragma: no cover - non-POSIX platforms
# On non-POSIX systems, fall back to assuming "not root".
is_root = False
if sudo_path is not None:
return "sudo "
if is_root:
return ""
return None
def _install_build_dependencies(self, ctx: RepoContext) -> None:
"""
Install build dependencies using `apt-get build-dep ./`.
@@ -86,12 +111,25 @@ class DebianControlInstaller(BaseInstaller):
)
return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic build-dep installation for Debian. "
"Please install build dependencies from debian/control manually."
)
return
# Update package lists first for reliable build-dep resolution.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
run_command(
f"{prefix}apt-get update",
cwd=ctx.repo_dir,
preview=ctx.preview,
)
# Install build dependencies based on debian/control in the current tree.
# `apt-get build-dep ./` uses the source in the current directory.
builddep_cmd = "sudo apt-get build-dep -y ./"
builddep_cmd = f"{prefix}apt-get build-dep -y ./"
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
@@ -101,7 +139,7 @@ class DebianControlInstaller(BaseInstaller):
Steps:
1. apt-get build-dep ./ (automatic build dependency installation)
2. dpkg-buildpackage -b -us -uc
3. sudo dpkg -i ../*.deb
3. sudo dpkg -i ../*.deb (or plain dpkg -i when running as root)
"""
control_path = self._control_path(ctx)
if not os.path.exists(control_path):
@@ -123,7 +161,17 @@ class DebianControlInstaller(BaseInstaller):
)
return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic .deb installation. "
"You can manually install the following files with dpkg -i:\n "
+ "\n ".join(debs)
)
return
# 4) Install .deb files
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs)
install_cmd = prefix + "dpkg -i " + " ".join(os.path.basename(d) for d in debs)
parent = os.path.dirname(ctx.repo_dir)
run_command(install_cmd, cwd=parent, preview=ctx.preview)

View File

@@ -7,8 +7,10 @@ Installer for RPM-based packages defined in *.spec files.
This installer:
1. Installs build dependencies via dnf/yum builddep (where available)
2. Uses rpmbuild to build RPMs from the provided .spec file
3. Installs the resulting RPMs via `rpm -i`
2. Prepares a source tarball in ~/rpmbuild/SOURCES based on the .spec
3. Uses rpmbuild to build RPMs from the provided .spec file
4. Installs the resulting RPMs via the system package manager (dnf/yum)
or rpm as a fallback.
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
"""
@@ -16,8 +18,8 @@ It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
import glob
import os
import shutil
from typing import List, Optional
import tarfile
from typing import List, Optional, Tuple
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
@@ -59,6 +61,117 @@ class RpmSpecInstaller(BaseInstaller):
return None
return matches[0]
# ------------------------------------------------------------------
# Helpers for preparing rpmbuild topdir and source tarball
# ------------------------------------------------------------------
def _rpmbuild_topdir(self) -> str:
"""
Return the rpmbuild topdir that rpmbuild will use by default.
By default this is: ~/rpmbuild
In the self-install tests, $HOME is set to /tmp/pkgmgr-self-install,
so this becomes /tmp/pkgmgr-self-install/rpmbuild which matches the
paths in the RPM build logs.
"""
home = os.path.expanduser("~")
return os.path.join(home, "rpmbuild")
def _ensure_rpmbuild_tree(self, topdir: str) -> None:
"""
Ensure the standard rpmbuild directory tree exists:
<topdir>/
BUILD/
BUILDROOT/
RPMS/
SOURCES/
SPECS/
SRPMS/
"""
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
"""
Parse Name and Version from the given .spec file.
Returns (name, version) or None if either cannot be determined.
"""
name = None
version = None
with open(spec_path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
# Ignore comments
if not line or line.startswith("#"):
continue
lower = line.lower()
if lower.startswith("name:"):
# e.g. "Name: package-manager"
parts = line.split(":", 1)
if len(parts) == 2:
name = parts[1].strip()
elif lower.startswith("version:"):
# e.g. "Version: 0.7.7"
parts = line.split(":", 1)
if len(parts) == 2:
version = parts[1].strip()
if name and version:
break
if not name or not version:
print(
"[Warning] Could not determine Name/Version from spec file "
f"'{spec_path}'. Skipping RPM source tarball preparation."
)
return None
return name, version
def _prepare_source_tarball(self, ctx: RepoContext, spec_path: str) -> None:
"""
Prepare a source tarball in <HOME>/rpmbuild/SOURCES that matches
the Name/Version in the .spec file.
"""
parsed = self._parse_name_version(spec_path)
if parsed is None:
return
name, version = parsed
topdir = self._rpmbuild_topdir()
self._ensure_rpmbuild_tree(topdir)
build_dir = os.path.join(topdir, "BUILD")
sources_dir = os.path.join(topdir, "SOURCES")
source_root = os.path.join(build_dir, f"{name}-{version}")
tarball_path = os.path.join(sources_dir, f"{name}-{version}.tar.gz")
# Clean any previous build directory for this name/version.
if os.path.exists(source_root):
shutil.rmtree(source_root)
# Copy the repository tree into BUILD/<name>-<version>.
shutil.copytree(ctx.repo_dir, source_root)
# Create the tarball with the top-level directory <name>-<version>.
if os.path.exists(tarball_path):
os.remove(tarball_path)
with tarfile.open(tarball_path, "w:gz") as tar:
tar.add(source_root, arcname=f"{name}-{version}")
print(
f"[INFO] Prepared RPM source tarball at '{tarball_path}' "
f"from '{ctx.repo_dir}'."
)
# ------------------------------------------------------------------
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
@@ -77,26 +190,13 @@ class RpmSpecInstaller(BaseInstaller):
By default, rpmbuild outputs RPMs into:
~/rpmbuild/RPMS/*/*.rpm
"""
home = os.path.expanduser("~")
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm")
topdir = self._rpmbuild_topdir()
pattern = os.path.join(topdir, "RPMS", "**", "*.rpm")
return sorted(glob.glob(pattern, recursive=True))
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
"""
Install build dependencies for the given .spec file.
Strategy (best-effort):
1. If dnf is available:
sudo dnf builddep -y <spec>
2. Else if yum-builddep is available:
sudo yum-builddep -y <spec>
3. Else if yum is available:
sudo yum-builddep -y <spec> # Some systems provide it via yum plugin
4. Otherwise: print a warning and skip automatic builddep install.
Any failure in builddep installation is treated as fatal (SystemExit),
consistent with other installer steps.
"""
spec_basename = os.path.basename(spec_path)
@@ -105,7 +205,6 @@ class RpmSpecInstaller(BaseInstaller):
elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None:
# Some distributions ship yum-builddep as a plugin.
cmd = f"sudo yum-builddep -y {spec_basename}"
else:
print(
@@ -114,33 +213,17 @@ class RpmSpecInstaller(BaseInstaller):
)
return
# Run builddep in the repository directory so relative spec paths work.
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
"""
Build and install RPM-based packages.
Install or upgrade the built RPMs.
Steps:
1. dnf/yum builddep <spec> (automatic build dependency installation)
2. rpmbuild -ba path/to/spec
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
Strategy:
- Prefer dnf install -y <rpms> (handles upgrades cleanly)
- Else yum install -y <rpms>
- Else fallback to rpm -Uvh <rpms> (upgrade/replace existing)
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 2) Build RPMs
# Use the full spec path, but run in the repo directory.
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Find built RPMs
rpms = self._find_built_rpms()
if not rpms:
print(
"[Warning] No RPM files found after rpmbuild. "
@@ -148,13 +231,52 @@ class RpmSpecInstaller(BaseInstaller):
)
return
# 4) Install RPMs
if shutil.which("rpm") is None:
dnf = shutil.which("dnf")
yum = shutil.which("yum")
rpm = shutil.which("rpm")
if dnf is not None:
install_cmd = "sudo dnf install -y " + " ".join(rpms)
elif yum is not None:
install_cmd = "sudo yum install -y " + " ".join(rpms)
elif rpm is not None:
# Fallback: use rpm in upgrade mode so an existing older
# version is replaced instead of causing file conflicts.
install_cmd = "sudo rpm -Uvh " + " ".join(rpms)
else:
print(
"[Warning] rpm binary not found on PATH. "
"[Warning] No suitable RPM installer (dnf/yum/rpm) found. "
"Cannot install built RPMs."
)
return
install_cmd = "sudo rpm -i " + " ".join(rpms)
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Build and install RPM-based packages.
Steps:
1. Prepare source tarball in ~/rpmbuild/SOURCES matching Name/Version
2. dnf/yum builddep <spec> (automatic build dependency installation)
3. rpmbuild -ba path/to/spec
4. Install built RPMs via dnf/yum (or rpm as fallback)
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Prepare source tarball so rpmbuild finds Source0 in SOURCES.
self._prepare_source_tarball(ctx, spec_path)
# 2) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 3) Build RPMs
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 4) Find and install built RPMs
rpms = self._find_built_rpms()
self._install_built_rpms(ctx, rpms)

View File

@@ -11,15 +11,28 @@ Strategy:
3. "pip" from PATH as last resort
- If pyproject.toml exists: pip install .
All installation failures are treated as fatal errors (SystemExit).
All installation failures are treated as fatal errors (SystemExit),
except when we explicitly skip the installer:
- If IN_NIX_SHELL is set, we assume Python is managed by Nix and
skip this installer entirely.
- If PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
"""
from __future__ import annotations
import os
import sys
from typing import TYPE_CHECKING
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
if TYPE_CHECKING:
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install import InstallContext
class PythonInstaller(BaseInstaller):
"""Install Python projects and dependencies via pip."""
@@ -27,19 +40,54 @@ class PythonInstaller(BaseInstaller):
# Logical layer name, used by capability matchers.
layer = "python"
def supports(self, ctx) -> bool:
def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the Python environment is already provided by Nix, so we must not
attempt an additional pip-based installation.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool:
"""
Return True if this installer should handle the given repository.
Only pyproject.toml is supported as the single source of truth
for Python dependencies and packaging metadata.
The installer is *disabled* when:
- IN_NIX_SHELL is set (Python managed by Nix dev shell), or
- PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set.
"""
# 1) Skip in Nix dev shells Python is managed by the flake/devShell.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping PythonInstaller. "
"Python runtime is provided by the Nix dev shell."
)
return False
# 2) Optional global kill-switch.
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_PYTHON_INSTALLER=1 "
"PythonInstaller is disabled."
)
return False
repo_dir = ctx.repo_dir
return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
def _pip_cmd(self) -> str:
"""
Resolve the pip command to use.
Order:
1) PKGMGR_PIP (explicit override)
2) sys.executable -m pip
3) plain "pip"
"""
explicit = os.environ.get("PKGMGR_PIP", "").strip()
if explicit:
@@ -50,12 +98,23 @@ class PythonInstaller(BaseInstaller):
return "pip"
def run(self, ctx) -> None:
def run(self, ctx: "InstallContext") -> None:
"""
Install Python project defined via pyproject.toml.
Any pip failure is propagated as SystemExit.
"""
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in PythonInstaller.run(); "
"skipping pip-based installation."
)
return
if not self.supports(ctx): # type: ignore[arg-type]
return
pip_cmd = self._pip_cmd()
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")

View File

@@ -1,35 +1,57 @@
import os
import subprocess
import sys
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.verify import verify_repository
def pull_with_verification(
selected_repos,
repositories_base_dir,
all_repos,
extra_args,
no_verification,
preview:bool):
preview: bool,
) -> None:
"""
Executes "git pull" for each repository with verification.
Uses the verify_repository function in "pull" mode.
If verification fails (and verification info is set) and --no-verification is not enabled,
the user is prompted to confirm the pull.
Execute `git pull` for each repository with verification.
- Uses verify_repository() in "pull" mode.
- If verification fails (and verification info is set) and
--no-verification is not enabled, the user is prompted to confirm
the pull.
- In preview mode, no interactive prompts are performed and no
Git commands are executed; only the would-be command is printed.
"""
for repo in selected_repos:
repo_identifier = get_repo_identifier(repo, all_repos)
repo_dir = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(repo_dir):
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
continue
verified_info = repo.get("verified")
verified_ok, errors, commit_hash, signing_key = verify_repository(repo, repo_dir, mode="pull", no_verification=no_verification)
verified_ok, errors, commit_hash, signing_key = verify_repository(
repo,
repo_dir,
mode="pull",
no_verification=no_verification,
)
if not no_verification and verified_info and not verified_ok:
# Only prompt the user if:
# - we are NOT in preview mode
# - verification is enabled
# - the repo has verification info configured
# - verification failed
if (
not preview
and not no_verification
and verified_info
and not verified_ok
):
print(f"Warning: Verification failed for {repo_identifier}:")
for err in errors:
print(f" - {err}")
@@ -37,12 +59,19 @@ def pull_with_verification(
if choice != "y":
continue
full_cmd = f"git pull {' '.join(extra_args)}"
# Build the git pull command (include extra args if present)
args_part = " ".join(extra_args) if extra_args else ""
full_cmd = f"git pull{(' ' + args_part) if args_part else ''}"
if preview:
# Preview mode: only show the command, do not execute or prompt.
print(f"[Preview] In '{repo_dir}': {full_cmd}")
else:
print(f"Running in '{repo_dir}': {full_cmd}")
result = subprocess.run(full_cmd, cwd=repo_dir, shell=True)
if result.returncode != 0:
print(f"'git pull' for {repo_identifier} failed with exit code {result.returncode}.")
print(
f"'git pull' for {repo_identifier} failed "
f"with exit code {result.returncode}."
)
sys.exit(result.returncode)

View File

@@ -8,6 +8,7 @@ import re
from typing import Any, Dict, List, Sequence
from pkgmgr.core.repository.resolve import resolve_repos
from pkgmgr.core.repository.ignored import filter_ignored
Repository = Dict[str, Any]
@@ -88,7 +89,7 @@ def _apply_filters(
if not _match_pattern(ident_str, string_pattern):
continue
# Category filter: nur echte Kategorien, KEINE Tags
# Category filter: only real categories, NOT tags
if category_patterns:
cats: List[str] = []
cats.extend(map(str, repo.get("category_files", [])))
@@ -106,7 +107,7 @@ def _apply_filters(
if not ok:
continue
# Tag filter: ausschließlich YAML-Tags
# Tag filter: YAML tags only
if tag_patterns:
tags: List[str] = list(map(str, repo.get("tags", [])))
if not tags:
@@ -124,16 +125,38 @@ def _apply_filters(
return filtered
def _maybe_filter_ignored(args, repos: List[Repository]) -> List[Repository]:
"""
Apply ignore filtering unless the caller explicitly opted to include ignored
repositories (via args.include_ignored).
Note: this helper is used only for *implicit* selections (all / filters /
by-directory). For *explicit* identifiers we do NOT filter ignored repos,
so the user can still target them directly if desired.
"""
include_ignored: bool = bool(getattr(args, "include_ignored", False))
if include_ignored:
return repos
return filter_ignored(repos)
def get_selected_repos(args, all_repositories: List[Repository]) -> List[Repository]:
"""
Compute the list of repositories selected by CLI arguments.
Modes:
- If identifiers are given: select via resolve_repos() from all_repositories.
- Else if any of --category/--string/--tag is used: start from all_repositories
and apply filters.
- Else if --all is set: select all_repositories.
- Else: try to select the repository of the current working directory.
Ignored repositories are *not* filtered here, so explicit identifiers
always win.
- Else if any of --category/--string/--tag is used: start from
all_repositories, apply filters and then drop ignored repos.
- Else if --all is set: select all_repositories and then drop ignored repos.
- Else: try to select the repository of the current working directory
and then drop it if it is ignored.
The ignore filter can be bypassed by setting args.include_ignored = True
(e.g. via a CLI flag --include-ignored).
"""
identifiers: List[str] = getattr(args, "identifiers", []) or []
use_all: bool = bool(getattr(args, "all", False))
@@ -143,18 +166,25 @@ def get_selected_repos(args, all_repositories: List[Repository]) -> List[Reposit
has_filters = bool(category_patterns or string_pattern or tag_patterns)
# 1) Explicit identifiers win
# 1) Explicit identifiers win and bypass ignore filtering
if identifiers:
base = resolve_repos(identifiers, all_repositories)
return _apply_filters(base, string_pattern, category_patterns, tag_patterns)
# 2) Filter-only mode: start from all repositories
if has_filters:
return _apply_filters(list(all_repositories), string_pattern, category_patterns, tag_patterns)
base = _apply_filters(
list(all_repositories),
string_pattern,
category_patterns,
tag_patterns,
)
return _maybe_filter_ignored(args, base)
# 3) --all (no filters): all repos
if use_all:
return list(all_repositories)
base = list(all_repositories)
return _maybe_filter_ignored(args, base)
# 4) Fallback: try to select repository of current working directory
cwd = os.path.abspath(os.getcwd())
@@ -164,7 +194,7 @@ def get_selected_repos(args, all_repositories: List[Repository]) -> List[Reposit
if os.path.abspath(str(repo.get("directory", ""))) == cwd
]
if by_dir:
return by_dir
return _maybe_filter_ignored(args, by_dir)
# No specific match -> empty list
return []

View File

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

View File

@@ -2,28 +2,59 @@
set -euo pipefail
# ---------------------------------------------------------------------------
# Ensure Nix has access to a valid CA bundle (TLS trust store)
# 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.)
# ---------------------------------------------------------------------------
if [[ -z "${NIX_SSL_CERT_FILE:-}" ]]; then
if [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then
# Debian/Ubuntu-style path
export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
elif [[ -f /etc/pki/tls/certs/ca-bundle.crt ]]; then
# Fedora/RHEL/CentOS-style path
export NIX_SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
else
echo "[docker] WARNING: No CA bundle found for Nix (NIX_SSL_CERT_FILE not set)."
echo "[docker] HTTPS access for Nix flakes may fail."
fi
detect_ca_bundle() {
# Common CA bundle locations across major Linux distributions
local candidates=(
/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
/etc/ssl/cert.pem # Some distros
/etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle
/etc/ssl/ca-bundle.pem # Generic fallback
)
for path in "${candidates[@]}"; do
if [[ -f "$path" ]]; then
echo "$path"
return 0
fi
done
return 1
}
# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect
CA_BUNDLE="${NIX_SSL_CERT_FILE:-}"
if [[ -z "${CA_BUNDLE}" ]]; then
CA_BUNDLE="$(detect_ca_bundle || true)"
fi
if [[ -n "${CA_BUNDLE}" ]]; then
# Export for Nix (critical)
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
# Export for Git, Python requests, curl, etc.
export SSL_CERT_FILE="${CA_BUNDLE}"
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
export GIT_SSL_CAINFO="${CA_BUNDLE}"
echo "[docker] Using CA bundle: ${CA_BUNDLE}"
else
echo "[docker] WARNING: No CA certificate bundle found."
echo "[docker] HTTPS access for Nix flakes and other tools may fail."
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[docker] Starting package-manager container"
# Distro info for logging
# ---------------------------------------------------------------------------
# Log distribution info
# ---------------------------------------------------------------------------
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
@@ -34,9 +65,9 @@ fi
echo "[docker] Using /src as working directory"
cd /src
# ------------------------------------------------------------
# DEV mode: build/install package-manager from current /src
# ------------------------------------------------------------
# ---------------------------------------------------------------------------
# DEV mode: rebuild package-manager from the mounted /src tree
# ---------------------------------------------------------------------------
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
@@ -49,9 +80,9 @@ if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
fi
fi
# ------------------------------------------------------------
# Hand-off to pkgmgr / arbitrary command
# ------------------------------------------------------------
# ---------------------------------------------------------------------------
# Hand off to pkgmgr or arbitrary command
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
echo "[docker] No arguments provided. Showing pkgmgr help..."
exec pkgmgr --help

View File

@@ -97,11 +97,32 @@ if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
useradd -m -r -g nixbld -s /usr/bin/bash nix
fi
# Create /nix directory and hand it to nix user (prevents installer sudo prompt)
# Ensure /nix exists and is writable by the "nix" user.
#
# In some base images (or previous runs), /nix may already exist and be
# owned by root. In that case the Nix single-user installer will abort with:
#
# "directory /nix exists, but is not writable by you"
#
# To keep container runs idempotent and robust, we always enforce
# ownership nix:nixbld here.
if [[ ! -d /nix ]]; then
echo "[init-nix] Creating /nix with owner nix:nixbld..."
mkdir -m 0755 /nix
chown nix:nixbld /nix
else
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
echo "[init-nix] /nix already exists with owner ${current_owner}:${current_group} fixing to nix:nixbld..."
chown -R nix:nixbld /nix
else
echo "[init-nix] /nix already exists with correct owner nix:nixbld."
fi
if [[ ! -w /nix ]]; then
echo "[init-nix] WARNING: /nix is still not writable after chown; Nix installer may fail."
fi
fi
# Run Nix single-user installer as "nix"

View File

@@ -18,6 +18,7 @@ for distro in $DISTROS; do
$MOUNT_NIX \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--workdir /src \
--entrypoint bash \
"package-manager-test-$distro" \
@@ -51,6 +52,6 @@ for distro in $DISTROS; do
nix develop .#default --no-write-lock-file -c \
python3 -m unittest discover \
-s /src/tests/e2e \
-p "test_*.py";
-p "$TEST_PATTERN";
'
done

View File

@@ -10,6 +10,7 @@ docker run --rm \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
--workdir /src \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--entrypoint bash \
"package-manager-test-arch" \
-c '
@@ -19,5 +20,5 @@ docker run --rm \
python -m unittest discover \
-s tests/integration \
-t /src \
-p "test_*.py";
-p "$TEST_PATTERN";
'

View File

@@ -10,6 +10,7 @@ docker run --rm \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
--workdir /src \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--entrypoint bash \
"package-manager-test-arch" \
-c '
@@ -19,5 +20,5 @@ docker run --rm \
python -m unittest discover \
-s tests/unit \
-t /src \
-p "test_*.py";
-p "$TEST_PATTERN";
'

View File

@@ -35,7 +35,7 @@ def remove_pkgmgr_from_nix_profile() -> None:
prints a descriptive format without an index column inside the container.
Instead, we directly try to remove possible names:
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'package-manager' (the name mentioned in Nix's own error hints)
"""
for spec in ("pkgmgr", "package-manager"):
@@ -76,29 +76,47 @@ def pkgmgr_help_debug() -> None:
print(f"returncode: {proc.returncode}")
print("--- END ---\n")
if proc.returncode != 0:
raise AssertionError(f"'pkgmgr --help' failed with exit code {proc.returncode}")
# Wichtig: Hier KEIN AssertionError mehr das ist reine Debug-Ausgabe.
# Falls du später hart testen willst, kannst du optional:
# if proc.returncode != 0:
# self.fail("...")
# aber aktuell nur Sichtprüfung.
# Important: this is **debug-only**. Do NOT fail the test here.
# If you ever want to hard-assert on this, you can add an explicit
# assertion in the test method instead of here.
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None:
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
"""
End-to-end test that runs "python main.py install pkgmgr ..." inside
the test container.
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
We isolate HOME into /tmp/pkgmgr-self-install so that:
- ~/.config/pkgmgr points to an isolated test config area
- ~/Repositories is owned by the current user inside the container
(avoiding Nix's 'repository path is not owned by current user' error)
"""
# Use a dedicated HOME for this test to avoid permission/ownership issues
temp_home = "/tmp/pkgmgr-self-install"
os.makedirs(temp_home, exist_ok=True)
original_argv = sys.argv
original_environ = os.environ.copy()
try:
# Isolate HOME so that ~ expands to /tmp/pkgmgr-self-install
os.environ["HOME"] = temp_home
# Optional: ensure XDG_* also use the temp HOME for extra isolation
os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(temp_home, ".config"))
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(temp_home, ".cache"))
os.environ.setdefault("XDG_DATA_HOME", os.path.join(temp_home, ".local", "share"))
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
sys.argv = [
"python",
"install",
@@ -107,13 +125,18 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
"shallow",
"--no-verification",
]
# Führt die Installation via main.py aus
# Run installation via main.py
runpy.run_module("main", run_name="__main__")
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
# After successful installation: run `pkgmgr --help` for debug
pkgmgr_help_debug()
finally:
sys.argv = original_argv
# Restore full environment
os.environ.clear()
os.environ.update(original_environ)
if __name__ == "__main__":

View File

@@ -80,6 +80,21 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
self.assertNotEqual(cm.exception.code, 0)
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
"""
If pyproject.toml does not exist, the function must not raise
and must not create the file. It should simply be skipped.
"""
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
self.assertFalse(os.path.exists(path))
# Must not raise an exception
update_pyproject_version(path, "1.2.3", preview=False)
# Still no file created
self.assertFalse(os.path.exists(path))
class TestUpdateFlakeVersion(unittest.TestCase):
def test_update_flake_version_normal(self) -> None:
@@ -352,11 +367,11 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Neue Stanza muss nach %changelog stehen
# New stanza must appear after the %changelog marker
self.assertIn("%changelog", content)
self.assertIn("Fedora changelog entry", content)
self.assertIn("Test Maintainer <test@example.com>", content)
# Alte Einträge müssen erhalten bleiben
# Old entries must still be present
self.assertIn("Old Maintainer <old@example.com>", content)
def test_update_spec_changelog_preview_does_not_write(self) -> None:
@@ -396,7 +411,7 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Im Preview-Modus darf nichts verändert werden
# In preview mode, the spec file must not change
self.assertEqual(content, original)

View File

@@ -0,0 +1,309 @@
import io
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.repository.pull import pull_with_verification
class TestPullWithVerification(unittest.TestCase):
"""
Comprehensive unit tests for pull_with_verification().
These tests verify:
- Preview mode behaviour
- Verification logic (prompting, bypassing, skipping)
- subprocess.run invocation
- Repository directory existence checks
- Handling of extra git pull arguments
"""
def _setup_mocks(self, mock_exists, mock_get_repo_id, mock_get_repo_dir,
mock_verify, exists=True, verified_ok=True,
errors=None, verified_info=True):
"""Helper to configure repetitive mock behavior."""
repo = {
"name": "pkgmgr",
"verified": {"gpg_keys": ["ABCDEF"]} if verified_info else None,
}
mock_exists.return_value = exists
mock_get_repo_id.return_value = "pkgmgr"
mock_get_repo_dir.return_value = "/fake/base/pkgmgr"
mock_verify.return_value = (
verified_ok,
errors or [],
"deadbeef", # commit hash
"ABCDEF", # signing key
)
return repo
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_preview_mode_non_interactive(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
Preview mode must NEVER request user input and must NEVER execute git.
It must only print the preview command.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
exists=True,
verified_ok=False,
errors=["bad signature"],
verified_info=True,
)
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=["--ff-only"],
no_verification=False,
preview=True,
)
output = buf.getvalue()
self.assertIn(
"[Preview] In '/fake/base/pkgmgr': git pull --ff-only",
output,
)
mock_input.assert_not_called()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_failure_user_declines(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification fails and preview=False, the user is prompted.
If the user declines ('n'), no git command is executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["signature invalid"],
)
mock_input.return_value = "n"
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
mock_input.assert_called_once()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_failure_user_accepts_runs_git(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification fails and the user accepts ('y'),
then the git pull should be executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["invalid"],
)
mock_input.return_value = "y"
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
mock_subprocess.assert_called_once()
mock_input.assert_called_once()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_success_no_prompt(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification is successful, the user should NOT be prompted,
and git pull should run immediately.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=True,
)
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=["--rebase"],
no_verification=False,
preview=False,
)
mock_input.assert_not_called()
mock_subprocess.assert_called_once()
cmd = mock_subprocess.call_args[0][0]
self.assertIn("git pull --rebase", cmd)
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_directory_missing_skips_repo(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If the repository directory does not exist, the repo must be skipped
silently and no git command executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
exists=False,
)
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
output = buf.getvalue()
self.assertIn("not found", output)
mock_input.assert_not_called()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_no_verification_flag_skips_prompt(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If no_verification=True, verification failures must NOT prompt.
Git pull should run directly.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["invalid"],
)
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=True,
preview=False,
)
mock_input.assert_not_called()
mock_subprocess.assert_called_once()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.actions.branch import open_branch
@@ -13,9 +12,10 @@ class TestOpenBranch(unittest.TestCase):
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 base
- pull base
- checkout resolved base
- pull resolved base
- create new branch
- push with upstream
"""
@@ -25,6 +25,7 @@ class TestOpenBranch(unittest.TestCase):
# 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"),
@@ -50,7 +51,7 @@ class TestOpenBranch(unittest.TestCase):
) -> None:
"""
If name is None/empty, open_branch should prompt via input()
and still perform the full Git sequence.
and still perform the full Git sequence on the resolved base.
"""
mock_run_git.return_value = ""
@@ -60,6 +61,7 @@ class TestOpenBranch(unittest.TestCase):
mock_input.assert_called_once()
expected_calls = [
(["rev-parse", "--verify", "develop"], "/repo"),
(["fetch", "origin"], "/repo"),
(["checkout", "develop"], "/repo"),
(["pull", "origin", "develop"], "/repo"),
@@ -76,15 +78,20 @@ class TestOpenBranch(unittest.TestCase):
self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_raises_runtimeerror_on_git_failure(self, mock_run_git) -> None:
def test_open_branch_raises_runtimeerror_on_fetch_failure(self, mock_run_git) -> None:
"""
If a GitError occurs (e.g. fetch fails), open_branch should
raise a RuntimeError with a helpful message.
If a GitError occurs on fetch, open_branch should raise a RuntimeError
with a helpful message.
"""
def side_effect(args, cwd="."):
# Simulate a failure on the first call (fetch)
raise GitError("simulated fetch failure")
# 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
@@ -95,6 +102,45 @@ class TestOpenBranch(unittest.TestCase):
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

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import unittest
from pkgmgr.core.repository.ignored import filter_ignored
class TestFilterIgnored(unittest.TestCase):
def test_filter_ignored_removes_repos_with_ignore_true(self) -> None:
repos = [
{"provider": "github.com", "account": "user", "repository": "a", "ignore": True},
{"provider": "github.com", "account": "user", "repository": "b", "ignore": False},
{"provider": "github.com", "account": "user", "repository": "c"},
]
result = filter_ignored(repos)
identifiers = {(r["provider"], r["account"], r["repository"]) for r in result}
self.assertNotIn(("github.com", "user", "a"), identifiers)
self.assertIn(("github.com", "user", "b"), identifiers)
self.assertIn(("github.com", "user", "c"), identifiers)
def test_filter_ignored_empty_list_returns_empty_list(self) -> None:
self.assertEqual(filter_ignored([]), [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.core.repository.selected import get_selected_repos
def _repo(
provider: str,
account: str,
repository: str,
ignore: bool | None = None,
directory: str | None = None,
):
repo = {
"provider": provider,
"account": account,
"repository": repository,
}
if ignore is not None:
repo["ignore"] = ignore
if directory is not None:
repo["directory"] = directory
return repo
class TestGetSelectedRepos(unittest.TestCase):
def setUp(self) -> None:
self.repo_ignored = _repo(
"github.com",
"user",
"ignored-repo",
ignore=True,
directory="/repos/github.com/user/ignored-repo",
)
self.repo_visible = _repo(
"github.com",
"user",
"visible-repo",
ignore=False,
directory="/repos/github.com/user/visible-repo",
)
self.all_repos = [self.repo_ignored, self.repo_visible]
# ------------------------------------------------------------------
# 1) Explizite Identifier ignorierte Repos dürfen ausgewählt werden
# ------------------------------------------------------------------
def test_identifiers_bypass_ignore_filter(self) -> None:
args = SimpleNamespace(
identifiers=["ignored-repo"], # matches by repository name
all=False,
category=[],
string="",
tag=[],
include_ignored=False, # should be ignored for explicit identifiers
)
selected = get_selected_repos(args, self.all_repos)
self.assertEqual(len(selected), 1)
self.assertIs(selected[0], self.repo_ignored)
# ------------------------------------------------------------------
# 2) Filter-only Modus ignorierte Repos werden rausgefiltert
# ------------------------------------------------------------------
def test_filter_mode_excludes_ignored_by_default(self) -> None:
# string-Filter, der beide Repos matchen würde
args = SimpleNamespace(
identifiers=[],
all=False,
category=[],
string="repo", # substring in beiden Namen
tag=[],
include_ignored=False,
)
selected = get_selected_repos(args, self.all_repos)
self.assertEqual(len(selected), 1)
self.assertIs(selected[0], self.repo_visible)
def test_filter_mode_can_include_ignored_when_flag_set(self) -> None:
args = SimpleNamespace(
identifiers=[],
all=False,
category=[],
string="repo",
tag=[],
include_ignored=True,
)
selected = get_selected_repos(args, self.all_repos)
# Beide Repos sollten erscheinen, weil include_ignored=True
self.assertEqual({r["repository"] for r in selected}, {"ignored-repo", "visible-repo"})
# ------------------------------------------------------------------
# 3) --all Modus ignorierte Repos werden per Default entfernt
# ------------------------------------------------------------------
def test_all_mode_excludes_ignored_by_default(self) -> None:
args = SimpleNamespace(
identifiers=[],
all=True,
category=[],
string="",
tag=[],
include_ignored=False,
)
selected = get_selected_repos(args, self.all_repos)
self.assertEqual(len(selected), 1)
self.assertIs(selected[0], self.repo_visible)
def test_all_mode_can_include_ignored_when_flag_set(self) -> None:
args = SimpleNamespace(
identifiers=[],
all=True,
category=[],
string="",
tag=[],
include_ignored=True,
)
selected = get_selected_repos(args, self.all_repos)
self.assertEqual(len(selected), 2)
self.assertCountEqual(
[r["repository"] for r in selected],
["ignored-repo", "visible-repo"],
)
# ------------------------------------------------------------------
# 4) CWD-Modus Repo anhand des aktuellen Verzeichnisses auswählen
# ------------------------------------------------------------------
def test_cwd_selection_excludes_ignored_by_default(self) -> None:
# Wir lassen CWD auf das Verzeichnis des ignorierten Repos zeigen.
cwd = os.path.abspath(self.repo_ignored["directory"])
args = SimpleNamespace(
identifiers=[],
all=False,
category=[],
string="",
tag=[],
include_ignored=False,
)
with patch("os.getcwd", return_value=cwd):
selected = get_selected_repos(args, self.all_repos)
# Da das einzige Repo für dieses Verzeichnis ignoriert ist,
# sollte die Auswahl leer sein.
self.assertEqual(selected, [])
def test_cwd_selection_can_include_ignored_when_flag_set(self) -> None:
cwd = os.path.abspath(self.repo_ignored["directory"])
args = SimpleNamespace(
identifiers=[],
all=False,
category=[],
string="",
tag=[],
include_ignored=True,
)
with patch("os.getcwd", return_value=cwd):
selected = get_selected_repos(args, self.all_repos)
self.assertEqual(len(selected), 1)
self.assertIs(selected[0], self.repo_ignored)
if __name__ == "__main__":
unittest.main()