Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b998741f | ||
|
|
172c734866 | ||
|
|
1b483e178d | ||
|
|
78693225f1 | ||
|
|
ca08c84789 | ||
|
|
e930b422e5 | ||
|
|
0833d04376 | ||
|
|
55f36d76ec | ||
|
|
6a838ee84f | ||
|
|
4285bf4a54 | ||
|
|
640b1042c2 | ||
|
|
9357c4632e | ||
|
|
ca5d0d22f3 | ||
|
|
3875338fb7 | ||
|
|
196f55c58e | ||
|
|
9a149715f6 | ||
|
|
bf40533469 | ||
|
|
7bc7259988 | ||
|
|
66b96ac3a5 | ||
|
|
f974e0b14a | ||
|
|
de8c3f768d | ||
|
|
05ff250251 | ||
|
|
ab52d37467 | ||
|
|
80329b85fb | ||
|
|
44ff0a6cd9 | ||
|
|
e00b1a7b69 | ||
|
|
14f0188efd | ||
|
|
a4efb847ba | ||
|
|
d50891dfe5 |
2
.github/workflows/test-container.yml
vendored
2
.github/workflows/test-container.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test Distribution Containers
|
||||
name: Test OS Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
2
.github/workflows/test-e2e.yml
vendored
2
.github/workflows/test-e2e.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test package-manager (e2e)
|
||||
name: Test End-To-End
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
8
.github/workflows/test-integration.yml
vendored
8
.github/workflows/test-integration.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/test-unit.yml
vendored
4
.github/workflows/test-unit.yml
vendored
@@ -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"
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,3 +1,63 @@
|
||||
## [0.7.11] - 2025-12-09
|
||||
|
||||
* test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
|
||||
## [0.7.10] - 2025-12-09
|
||||
|
||||
* Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
## [0.7.1] - 2025-12-09
|
||||
|
||||
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
|
||||
|
||||
|
||||
## [0.7.0] - 2025-12-09
|
||||
|
||||
* Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff)
|
||||
|
||||
|
||||
## [0.6.0] - 2025-12-09
|
||||
|
||||
* Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile.
|
||||
|
||||
16
Makefile
16
Makefile
@@ -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)
|
||||
|
||||
2
PKGBUILD
2
PKGBUILD
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=0.6.0
|
||||
pkgver=0.7.11
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
72
debian/changelog
vendored
72
debian/changelog
vendored
@@ -1,3 +1,75 @@
|
||||
package-manager (0.7.11-1) unstable; urgency=medium
|
||||
|
||||
* test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:16:46 +0100
|
||||
|
||||
package-manager (0.7.10-1) unstable; urgency=medium
|
||||
|
||||
* Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 22:57:08 +0100
|
||||
|
||||
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
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:48:58 +0100
|
||||
|
||||
package-manager (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:26:54 +0100
|
||||
|
||||
package-manager (0.7.0-1) unstable; urgency=medium
|
||||
|
||||
* Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff)
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:21:03 +0100
|
||||
|
||||
package-manager (0.6.0-1) unstable; urgency=medium
|
||||
|
||||
* Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "0.6.0";
|
||||
version = "0.7.11";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 0.6.0
|
||||
Version: 0.7.11
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -77,5 +77,35 @@ 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.11-1
|
||||
- test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.10-1
|
||||
- Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
* 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
|
||||
|
||||
* Sat Dec 06 2025 Kevin Veen-Birkenbach <info@veen.world> - 0.1.1-1
|
||||
- Initial RPM packaging for package-manager
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# pkgmgr/branch_commands.py
|
||||
# pkgmgr/actions/branch/__init__.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -6,40 +6,53 @@
|
||||
High-level helpers for branch-related operations.
|
||||
|
||||
This module encapsulates the actual Git logic so the CLI layer
|
||||
(pkgmgr.cli_core.commands.branch) stays thin and testable.
|
||||
(pkgmgr.cli.commands.branch) stays thin and testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.git_utils import run_git, GitError, get_current_branch
|
||||
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
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.git_utils import run_git, GitError
|
||||
from pkgmgr.core.git import run_git, GitError
|
||||
|
||||
|
||||
def generate_changelog(
|
||||
@@ -26,8 +26,8 @@ import os
|
||||
import subprocess
|
||||
from typing import Any, Dict
|
||||
|
||||
from pkgmgr.generate_alias import generate_alias
|
||||
from pkgmgr.save_user_config import save_user_config
|
||||
from pkgmgr.core.command.alias import generate_alias
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
|
||||
|
||||
def config_init(
|
||||
@@ -1,5 +1,5 @@
|
||||
import yaml
|
||||
from .load_config import load_config
|
||||
from pkgmgr.core.config.load import load_config
|
||||
|
||||
def show_config(selected_repos, user_config_path, full_config=False):
|
||||
"""Display configuration for one or more repositories, or the entire merged config."""
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.command.run import run_command
|
||||
import sys
|
||||
|
||||
def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir, all_repos, proxy_command: str, extra_args, preview: bool):
|
||||
310
pkgmgr/actions/release/__init__.py
Normal file
310
pkgmgr/actions/release/__init__.py
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Release helper for pkgmgr (public entry point).
|
||||
|
||||
This package provides the high-level `release()` function used by the
|
||||
pkgmgr CLI to perform versioned releases:
|
||||
|
||||
- Determine the next semantic version based on existing Git tags.
|
||||
- Update pyproject.toml with the new version.
|
||||
- Update additional packaging files (flake.nix, PKGBUILD,
|
||||
debian/changelog, RPM spec) where present.
|
||||
- Prepend a basic entry to CHANGELOG.md.
|
||||
- Move the floating 'latest' tag to the newly created release tag so
|
||||
the newest release is always marked as latest.
|
||||
|
||||
Additional behaviour:
|
||||
- If `preview=True` (from --preview), no files are written and no
|
||||
Git commands are executed. Instead, a detailed summary of the
|
||||
planned changes and commands is printed.
|
||||
- If `preview=False` and not forced, the release is executed in two
|
||||
phases:
|
||||
1) Preview-only run (dry-run).
|
||||
2) Interactive confirmation, then real release if confirmed.
|
||||
This confirmation can be skipped with the `force=True` flag.
|
||||
- Before creating and pushing tags, main/master is updated from origin
|
||||
when the release is performed on one of these branches.
|
||||
- If `close=True` is used and the current branch is not main/master,
|
||||
the branch will be closed via branch_commands.close_branch() after
|
||||
a successful release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git import get_current_branch, GitError
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
|
||||
from .versioning import determine_current_version, bump_semver
|
||||
from .git_ops import run_git_command, sync_branch_with_remote, update_latest_tag
|
||||
from .files import (
|
||||
update_pyproject_version,
|
||||
update_flake_version,
|
||||
update_pkgbuild_version,
|
||||
update_spec_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_spec_changelog,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal implementation (single-phase, preview or real)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Internal implementation that performs a single-phase release.
|
||||
"""
|
||||
current_ver = determine_current_version()
|
||||
new_ver = bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
|
||||
mode = "PREVIEW" if preview else "REAL"
|
||||
print(f"Release mode: {mode}")
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
|
||||
# Update core project metadata and packaging files
|
||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||
changelog_message = update_changelog(
|
||||
changelog_path,
|
||||
new_ver_str,
|
||||
message=message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
flake_path = os.path.join(repo_root, "flake.nix")
|
||||
update_flake_version(flake_path, new_ver_str, preview=preview)
|
||||
|
||||
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
||||
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
||||
|
||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||
|
||||
# Determine a single effective_message to be reused across all
|
||||
# changelog targets (project, Debian, Fedora).
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
|
||||
# Debian changelog
|
||||
update_debian_changelog(
|
||||
debian_changelog_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
# Fedora / RPM %changelog
|
||||
update_spec_changelog(
|
||||
spec_path=spec_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
# Determine branch and ensure it is up to date if main/master
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitError:
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
# Ensure main/master are up-to-date from origin before creating and
|
||||
# pushing tags. For other branches we only log the intent.
|
||||
sync_branch_with_remote(branch, preview=preview)
|
||||
|
||||
files_to_add = [
|
||||
pyproject_path,
|
||||
changelog_path,
|
||||
flake_path,
|
||||
pkgbuild_path,
|
||||
spec_path,
|
||||
debian_changelog_path,
|
||||
]
|
||||
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
||||
|
||||
if preview:
|
||||
for path in existing_files:
|
||||
print(f"[PREVIEW] Would run: git add {path}")
|
||||
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||
print("[PREVIEW] Would run: git push origin --tags")
|
||||
|
||||
# Also update the floating 'latest' tag to the new highest SemVer.
|
||||
update_latest_tag(new_tag, preview=True)
|
||||
|
||||
if close and branch not in ("main", "master"):
|
||||
print(
|
||||
f"[PREVIEW] Would also close branch {branch} after the release "
|
||||
"(close=True and branch is not main/master)."
|
||||
)
|
||||
elif close:
|
||||
print(
|
||||
f"[PREVIEW] close=True but current branch is {branch}; "
|
||||
"no branch would be closed."
|
||||
)
|
||||
|
||||
print("Preview completed. No changes were made.")
|
||||
return
|
||||
|
||||
for path in existing_files:
|
||||
run_git_command(f"git add {path}")
|
||||
|
||||
run_git_command(f'git commit -am "{commit_msg}"')
|
||||
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
||||
run_git_command(f"git push origin {branch}")
|
||||
run_git_command("git push origin --tags")
|
||||
|
||||
# Move 'latest' to the new release tag so the newest SemVer is always
|
||||
# marked as latest. This is best-effort and must not break the release.
|
||||
try:
|
||||
update_latest_tag(new_tag, preview=False)
|
||||
except GitError as exc: # pragma: no cover
|
||||
print(
|
||||
f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}\n"
|
||||
"[WARN] The release itself completed successfully; only the "
|
||||
"'latest' tag was not updated."
|
||||
)
|
||||
|
||||
print(f"Release {new_ver_str} completed.")
|
||||
|
||||
if close:
|
||||
if branch in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] close=True but current branch is {branch}; "
|
||||
"nothing to close."
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[INFO] Closing branch {branch} after successful release "
|
||||
"(close=True and branch is not main/master)..."
|
||||
)
|
||||
try:
|
||||
close_branch(name=branch, base_branch="main", cwd=".")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public release entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
High-level release entry point.
|
||||
|
||||
Modes:
|
||||
|
||||
- preview=True:
|
||||
* Single-phase PREVIEW only.
|
||||
|
||||
- preview=False, force=True:
|
||||
* Single-phase REAL release, no interactive preview.
|
||||
|
||||
- preview=False, force=False:
|
||||
* Two-phase flow (intended default for interactive CLI use).
|
||||
"""
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if force:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
print("[INFO] Running preview before actual release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
|
||||
try:
|
||||
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n[INFO] Release aborted (no confirmation).")
|
||||
return
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
print("Release aborted by user. No changes were made.")
|
||||
return
|
||||
|
||||
print("\n[INFO] Running REAL release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["release"]
|
||||
@@ -1,32 +1,17 @@
|
||||
# pkgmgr/release.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
pkgmgr/release.py
|
||||
File and metadata update helpers for the release workflow.
|
||||
|
||||
Release helper for pkgmgr.
|
||||
|
||||
Responsibilities (Milestone 7):
|
||||
- Determine the next semantic version based on existing Git tags.
|
||||
Responsibilities:
|
||||
- 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.
|
||||
- Commit, tag, and push the release on the current branch.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- Update flake.nix, PKGBUILD, RPM spec files where present.
|
||||
- Prepend release entries to CHANGELOG.md.
|
||||
- Maintain distribution-specific changelog files:
|
||||
* debian/changelog
|
||||
* RPM spec %changelog section
|
||||
including maintainer metadata where applicable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,84 +24,6 @@ import tempfile
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pkgmgr.git_utils import get_tags, get_current_branch, GitError
|
||||
from pkgmgr.branch_commands import close_branch
|
||||
from pkgmgr.versioning import (
|
||||
SemVer,
|
||||
find_latest_version,
|
||||
bump_major,
|
||||
bump_minor,
|
||||
bump_patch,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for Git + version discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _determine_current_version() -> SemVer:
|
||||
"""
|
||||
Determine the current semantic version from Git tags.
|
||||
|
||||
Behaviour:
|
||||
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
|
||||
- Otherwise, use the latest SemVer tag as current version.
|
||||
"""
|
||||
tags = get_tags()
|
||||
if not tags:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
latest = find_latest_version(tags)
|
||||
if latest is None:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
_tag, ver = latest
|
||||
return ver
|
||||
|
||||
|
||||
def _bump_semver(current: SemVer, release_type: str) -> SemVer:
|
||||
"""
|
||||
Bump the given SemVer according to the release type.
|
||||
|
||||
release_type must be one of: "major", "minor", "patch".
|
||||
"""
|
||||
if release_type == "major":
|
||||
return bump_major(current)
|
||||
if release_type == "minor":
|
||||
return bump_minor(current)
|
||||
if release_type == "patch":
|
||||
return bump_patch(current)
|
||||
|
||||
raise ValueError(f"Unknown release type: {release_type!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level Git command helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_git_command(cmd: str) -> None:
|
||||
"""
|
||||
Run a Git (or shell) command with basic error reporting.
|
||||
|
||||
The command is executed via the shell, primarily for readability
|
||||
when printed (as in 'git commit -am "msg"').
|
||||
"""
|
||||
print(f"[GIT] {cmd}")
|
||||
try:
|
||||
subprocess.run(cmd, shell=True, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"[ERROR] Git command failed: {cmd}")
|
||||
print(f" Exit code: {exc.returncode}")
|
||||
if exc.stdout:
|
||||
print("--- stdout ---")
|
||||
print(exc.stdout)
|
||||
if exc.stderr:
|
||||
print("--- stderr ---")
|
||||
print(exc.stderr)
|
||||
raise GitError(f"Git command failed: {cmd}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Editor helper for interactive changelog messages
|
||||
@@ -178,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,
|
||||
@@ -192,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(
|
||||
@@ -541,221 +459,79 @@ def update_debian_changelog(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal implementation (single-phase, preview or real)
|
||||
# Fedora / RPM spec %changelog helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
def update_spec_changelog(
|
||||
spec_path: str,
|
||||
package_name: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Internal implementation that performs a single-phase release.
|
||||
Prepend a new entry to the %changelog section of an RPM spec file,
|
||||
if present.
|
||||
|
||||
Typical RPM-style entry:
|
||||
|
||||
* Tue Dec 09 2025 John Doe <john@example.com> - 0.5.1-1
|
||||
- Your changelog message
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitError:
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
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")
|
||||
|
||||
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.")
|
||||
if not os.path.exists(spec_path):
|
||||
print("[INFO] RPM spec file not found, skipping spec changelog update.")
|
||||
return
|
||||
|
||||
for path in existing_files:
|
||||
_run_git_command(f"git add {path}")
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read spec file for changelog update: {exc}")
|
||||
return
|
||||
|
||||
_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")
|
||||
debian_version = f"{new_version}-1"
|
||||
now = datetime.now().astimezone()
|
||||
date_str = now.strftime("%a %b %d %Y")
|
||||
|
||||
print(f"Release {new_ver_str} completed.")
|
||||
# Reuse Debian maintainer discovery for author name/email.
|
||||
author_name, author_email = _get_debian_author()
|
||||
|
||||
if close:
|
||||
if branch in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] close=True but current branch is {branch}; "
|
||||
"nothing to close."
|
||||
)
|
||||
return
|
||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||
|
||||
stanza = (
|
||||
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
|
||||
f"- {body_line}\n\n"
|
||||
)
|
||||
|
||||
marker = "%changelog"
|
||||
idx = content.find(marker)
|
||||
|
||||
if idx == -1:
|
||||
# No %changelog section yet: append one at the end.
|
||||
new_content = content.rstrip() + "\n\n%changelog\n" + stanza
|
||||
else:
|
||||
# Insert stanza right after the %changelog line.
|
||||
before = content[: idx + len(marker)]
|
||||
after = content[idx + len(marker) :]
|
||||
new_content = before + "\n" + stanza + after.lstrip("\n")
|
||||
|
||||
if preview:
|
||||
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,
|
||||
"[PREVIEW] Would update RPM %changelog section with the following "
|
||||
"stanza:\n"
|
||||
f"{stanza}"
|
||||
)
|
||||
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).")
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
|
||||
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,
|
||||
print(
|
||||
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
|
||||
f"for {package_name} {debian_version}"
|
||||
)
|
||||
95
pkgmgr/actions/release/git_ops.py
Normal file
95
pkgmgr/actions/release/git_ops.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Git-related helpers for the release workflow.
|
||||
|
||||
Responsibilities:
|
||||
- Run Git (or shell) commands with basic error reporting.
|
||||
- Ensure main/master are synchronized with origin before tagging.
|
||||
- Maintain the floating 'latest' tag that always points to the newest
|
||||
release tag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from pkgmgr.core.git import GitError
|
||||
|
||||
|
||||
def run_git_command(cmd: str) -> None:
|
||||
"""
|
||||
Run a Git (or shell) command with basic error reporting.
|
||||
|
||||
The command is executed via the shell, primarily for readability
|
||||
when printed (as in 'git commit -am "msg"').
|
||||
"""
|
||||
print(f"[GIT] {cmd}")
|
||||
try:
|
||||
subprocess.run(cmd, shell=True, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"[ERROR] Git command failed: {cmd}")
|
||||
print(f" Exit code: {exc.returncode}")
|
||||
if exc.stdout:
|
||||
print("--- stdout ---")
|
||||
print(exc.stdout)
|
||||
if exc.stderr:
|
||||
print("--- stderr ---")
|
||||
print(exc.stderr)
|
||||
raise GitError(f"Git command failed: {cmd}") from exc
|
||||
|
||||
|
||||
def sync_branch_with_remote(branch: str, preview: bool = False) -> None:
|
||||
"""
|
||||
Ensure the local main/master branch is up-to-date before tagging.
|
||||
|
||||
Behaviour:
|
||||
- For main/master: run 'git fetch origin' and 'git pull origin <branch>'.
|
||||
- For all other branches: only log that no automatic sync is performed.
|
||||
"""
|
||||
if branch not in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] Skipping automatic git pull for non-main/master branch "
|
||||
f"{branch}."
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[INFO] Updating branch {branch} from origin before creating tags..."
|
||||
)
|
||||
|
||||
if preview:
|
||||
print("[PREVIEW] Would run: git fetch origin")
|
||||
print(f"[PREVIEW] Would run: git pull origin {branch}")
|
||||
return
|
||||
|
||||
run_git_command("git fetch origin")
|
||||
run_git_command(f"git pull origin {branch}")
|
||||
|
||||
|
||||
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
|
||||
"""
|
||||
Move the floating 'latest' tag to the newly created release tag.
|
||||
|
||||
Implementation details:
|
||||
- We explicitly dereference the tag object via `<tag>^{}` so that
|
||||
'latest' always points at the underlying commit, not at another tag.
|
||||
- We create/update 'latest' as an annotated tag with a short message so
|
||||
Git configurations that enforce annotated/signed tags do not fail
|
||||
with "no tag message".
|
||||
"""
|
||||
target_ref = f"{new_tag}^{{}}"
|
||||
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} "
|
||||
f'-m "Floating latest tag for {new_tag}"')
|
||||
print("[PREVIEW] Would run: git push origin latest --force")
|
||||
return
|
||||
|
||||
run_git_command(
|
||||
f'git tag -f -a latest {target_ref} '
|
||||
f'-m "Floating latest tag for {new_tag}"'
|
||||
)
|
||||
run_git_command("git push origin latest --force")
|
||||
53
pkgmgr/actions/release/versioning.py
Normal file
53
pkgmgr/actions/release/versioning.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Version discovery and bumping helpers for the release workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
find_latest_version,
|
||||
bump_major,
|
||||
bump_minor,
|
||||
bump_patch,
|
||||
)
|
||||
|
||||
|
||||
def determine_current_version() -> SemVer:
|
||||
"""
|
||||
Determine the current semantic version from Git tags.
|
||||
|
||||
Behaviour:
|
||||
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
|
||||
- Otherwise, use the latest SemVer tag as current version.
|
||||
"""
|
||||
tags = get_tags()
|
||||
if not tags:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
latest = find_latest_version(tags)
|
||||
if latest is None:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
_tag, ver = latest
|
||||
return ver
|
||||
|
||||
|
||||
def bump_semver(current: SemVer, release_type: str) -> SemVer:
|
||||
"""
|
||||
Bump the given SemVer according to the release type.
|
||||
|
||||
release_type must be one of: "major", "minor", "patch".
|
||||
"""
|
||||
if release_type == "major":
|
||||
return bump_major(current)
|
||||
if release_type == "minor":
|
||||
return bump_minor(current)
|
||||
if release_type == "patch":
|
||||
return bump_patch(current)
|
||||
|
||||
raise ValueError(f"Unknown release type: {release_type!r}")
|
||||
@@ -1,8 +1,8 @@
|
||||
import subprocess
|
||||
import os
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.verify import verify_repository
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
def clone_repos(
|
||||
selected_repos,
|
||||
@@ -2,8 +2,8 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import yaml
|
||||
from pkgmgr.generate_alias import generate_alias
|
||||
from pkgmgr.save_user_config import save_user_config
|
||||
from pkgmgr.core.command.alias import generate_alias
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
|
||||
def create_repo(identifier, config_merged, user_config_path, bin_dir, remote=False, preview=False):
|
||||
"""
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
def deinstall_repos(selected_repos, repositories_base_dir, bin_dir, all_repos, preview=False):
|
||||
for repo in selected_repos:
|
||||
@@ -1,7 +1,7 @@
|
||||
import shutil
|
||||
import os
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False):
|
||||
for repo in selected_repos:
|
||||
@@ -21,23 +21,23 @@ focused installer classes.
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.create_ink import create_ink
|
||||
from pkgmgr.verify import verify_repository
|
||||
from pkgmgr.clone_repos import clone_repos
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.resolve_command import resolve_command_for_repo
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.command.ink import create_ink
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.core.command.resolve import resolve_command_for_repo
|
||||
|
||||
# Installer implementations
|
||||
from pkgmgr.installers.os_packages import (
|
||||
from pkgmgr.actions.repository.install.installers.os_packages import (
|
||||
ArchPkgbuildInstaller,
|
||||
DebianControlInstaller,
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
from pkgmgr.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.installers.python import PythonInstaller
|
||||
from pkgmgr.installers.makefile import MakefileInstaller
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
||||
|
||||
|
||||
# Layering:
|
||||
@@ -38,7 +38,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Iterable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
19
pkgmgr/actions/repository/install/installers/__init__.py
Normal file
19
pkgmgr/actions/repository/install/installers/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
This exposes all installer classes so users can import them directly from
|
||||
pkgmgr.actions.repository.install.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
@@ -8,8 +8,8 @@ Base interface for all installer components in the pkgmgr installation pipeline.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Set
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.capabilities import CAPABILITY_MATCHERS
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.capabilities import CAPABILITY_MATCHERS
|
||||
|
||||
|
||||
class BaseInstaller(ABC):
|
||||
@@ -12,9 +12,9 @@ installation step.
|
||||
import os
|
||||
import re
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class MakefileInstaller(BaseInstaller):
|
||||
@@ -13,18 +13,25 @@ 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
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.install_repos import InstallContext
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install import InstallContext
|
||||
|
||||
|
||||
class NixFlakeInstaller(BaseInstaller):
|
||||
@@ -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}")
|
||||
@@ -3,9 +3,9 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class ArchPkgbuildInstaller(BaseInstaller):
|
||||
@@ -17,12 +17,11 @@ apt/dpkg tooling are available.
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class DebianControlInstaller(BaseInstaller):
|
||||
@@ -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)
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for RPM-based packages defined in *.spec files.
|
||||
|
||||
This installer:
|
||||
|
||||
1. Installs build dependencies via dnf/yum builddep (where available)
|
||||
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.).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
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
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class RpmSpecInstaller(BaseInstaller):
|
||||
"""
|
||||
Build and install RPM-based packages from *.spec files.
|
||||
|
||||
This installer is responsible for the full build + install of the
|
||||
application on RPM-like systems.
|
||||
"""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "os-packages"
|
||||
|
||||
def _is_rpm_like(self) -> bool:
|
||||
"""
|
||||
Basic RPM-like detection:
|
||||
|
||||
- rpmbuild must be available
|
||||
- at least one of dnf / yum / yum-builddep must be present
|
||||
"""
|
||||
if shutil.which("rpmbuild") is None:
|
||||
return False
|
||||
|
||||
has_dnf = shutil.which("dnf") is not None
|
||||
has_yum = shutil.which("yum") is not None
|
||||
has_yum_builddep = shutil.which("yum-builddep") is not None
|
||||
|
||||
return has_dnf or has_yum or has_yum_builddep
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
"""Return the first *.spec file in the repository root, if any."""
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
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:
|
||||
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
|
||||
- a *.spec file exists in the repository root.
|
||||
"""
|
||||
if not self._is_rpm_like():
|
||||
return False
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _find_built_rpms(self) -> List[str]:
|
||||
"""
|
||||
Find RPMs built by rpmbuild.
|
||||
|
||||
By default, rpmbuild outputs RPMs into:
|
||||
~/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.
|
||||
"""
|
||||
spec_basename = os.path.basename(spec_path)
|
||||
|
||||
if shutil.which("dnf") is not None:
|
||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||
elif shutil.which("yum-builddep") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
elif shutil.which("yum") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
else:
|
||||
print(
|
||||
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
|
||||
"Skipping automatic build dependency installation for RPM."
|
||||
)
|
||||
return
|
||||
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
|
||||
"""
|
||||
Install or upgrade the built RPMs.
|
||||
|
||||
Strategy:
|
||||
- Prefer dnf install -y <rpms> (handles upgrades cleanly)
|
||||
- Else yum install -y <rpms>
|
||||
- Else fallback to rpm -Uvh <rpms> (upgrade/replace existing)
|
||||
"""
|
||||
if not rpms:
|
||||
print(
|
||||
"[Warning] No RPM files found after rpmbuild. "
|
||||
"Skipping RPM package installation."
|
||||
)
|
||||
return
|
||||
|
||||
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] No suitable RPM installer (dnf/yum/rpm) found. "
|
||||
"Cannot install built RPMs."
|
||||
)
|
||||
return
|
||||
|
||||
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)
|
||||
127
pkgmgr/actions/repository/install/installers/python.py
Normal file
127
pkgmgr/actions/repository/install/installers/python.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Python projects based on pyproject.toml.
|
||||
|
||||
Strategy:
|
||||
- Determine a pip command in this order:
|
||||
1. $PKGMGR_PIP (explicit override, e.g. ~/.venvs/pkgmgr/bin/pip)
|
||||
2. sys.executable -m pip (current interpreter)
|
||||
3. "pip" from PATH as last resort
|
||||
- If pyproject.toml exists: pip install .
|
||||
|
||||
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."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "python"
|
||||
|
||||
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:
|
||||
return explicit
|
||||
|
||||
if sys.executable:
|
||||
return f"{sys.executable} -m pip"
|
||||
|
||||
return "pip"
|
||||
|
||||
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")
|
||||
if os.path.exists(pyproject):
|
||||
print(
|
||||
f"pyproject.toml found in {ctx.identifier}, "
|
||||
f"installing Python project..."
|
||||
)
|
||||
cmd = f"{pip_cmd} install ."
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
77
pkgmgr/actions/repository/pull.py
Normal file
77
pkgmgr/actions/repository/pull.py
Normal file
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
) -> None:
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
# 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}")
|
||||
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
|
||||
if choice != "y":
|
||||
continue
|
||||
|
||||
# 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 "
|
||||
f"with exit code {result.returncode}."
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
@@ -1,9 +1,9 @@
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
from .exec_proxy_command import exec_proxy_command
|
||||
from .run_command import run_command
|
||||
from .get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
|
||||
def status_repos(
|
||||
@@ -1,8 +1,8 @@
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
from pkgmgr.pull_with_verification import pull_with_verification
|
||||
from pkgmgr.install_repos import install_repos
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
|
||||
|
||||
def update_repos(
|
||||
@@ -54,7 +54,7 @@ def update_repos(
|
||||
)
|
||||
|
||||
if system_update:
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
# Nix: upgrade all profile entries (if Nix is available)
|
||||
if shutil.which("nix") is not None:
|
||||
13
pkgmgr/cli.py → pkgmgr/cli/__init__.py
Executable file → Normal file
13
pkgmgr/cli.py → pkgmgr/cli/__init__.py
Executable file → Normal file
@@ -1,13 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.load_config import load_config
|
||||
from pkgmgr.cli_core import CLIContext, create_parser, dispatch_command
|
||||
from pkgmgr.core.config.load import load_config
|
||||
|
||||
from .context import CLIContext
|
||||
from .parser import create_parser
|
||||
from .dispatch import dispatch_command
|
||||
|
||||
__all__ = ["CLIContext", "create_parser", "dispatch_command", "main"]
|
||||
|
||||
|
||||
# User config lives in the home directory:
|
||||
# ~/.config/pkgmgr/config.yaml
|
||||
@@ -1,10 +1,9 @@
|
||||
# pkgmgr/cli_core/commands/branch.py
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.branch_commands import open_branch, close_branch
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.branch import open_branch, close_branch
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
@@ -4,12 +4,12 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.git_utils import get_tags
|
||||
from pkgmgr.versioning import SemVer, extract_semver_from_tags
|
||||
from pkgmgr.changelog import generate_changelog
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.version.semver import SemVer, extract_semver_from_tags
|
||||
from pkgmgr.actions.changelog import generate_changelog
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
@@ -11,13 +11,13 @@ from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.config_init import config_init
|
||||
from pkgmgr.interactive_add import interactive_add
|
||||
from pkgmgr.resolve_repos import resolve_repos
|
||||
from pkgmgr.save_user_config import save_user_config
|
||||
from pkgmgr.show_config import show_config
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.config.init import config_init
|
||||
from pkgmgr.actions.config.add import interactive_add
|
||||
from pkgmgr.core.repository.resolve import resolve_repos
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
from pkgmgr.actions.config.show import show_config
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.exec_proxy_command import exec_proxy_command
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
@@ -1,4 +1,3 @@
|
||||
# pkgmgr/cli_core/commands/release.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -6,14 +5,14 @@
|
||||
Release command wiring for the pkgmgr CLI.
|
||||
|
||||
This module implements the `pkgmgr release` subcommand on top of the
|
||||
generic selection logic from cli_core.dispatch. It does not define its
|
||||
own subparser; the CLI surface is configured in cli_core.parser.
|
||||
generic selection logic from cli.dispatch. It does not define its
|
||||
own subparser; the CLI surface is configured in cli.parser.
|
||||
|
||||
Responsibilities:
|
||||
- Take the parsed argparse.Namespace for the `release` command.
|
||||
- Use the list of selected repositories provided by dispatch_command().
|
||||
- Optionally list affected repositories when --list is set.
|
||||
- For each selected repository, run pkgmgr.release.release(...) in
|
||||
- For each selected repository, run pkgmgr.actions.release.release(...) in
|
||||
the context of that repository directory.
|
||||
"""
|
||||
|
||||
@@ -22,10 +21,10 @@ from __future__ import annotations
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.release import release as run_release
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.actions.release import release as run_release
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
@@ -46,7 +45,7 @@ def handle_release(
|
||||
3) For each selected repository:
|
||||
- Resolve its identifier and local directory.
|
||||
- Change into that directory.
|
||||
- Call pkgmgr.release.release(...) with the parsed options.
|
||||
- Call pkgmgr.actions.release.release(...) with the parsed options.
|
||||
"""
|
||||
if not selected:
|
||||
print("[pkgmgr] No repositories selected for release.")
|
||||
@@ -6,16 +6,16 @@ from __future__ import annotations
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.install_repos import install_repos
|
||||
from pkgmgr.deinstall_repos import deinstall_repos
|
||||
from pkgmgr.delete_repos import delete_repos
|
||||
from pkgmgr.update_repos import update_repos
|
||||
from pkgmgr.status_repos import status_repos
|
||||
from pkgmgr.list_repositories import list_repositories
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.create_repo import create_repo
|
||||
from pkgmgr.get_selected_repos import get_selected_repos
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
from pkgmgr.actions.repository.deinstall import deinstall_repos
|
||||
from pkgmgr.actions.repository.delete import delete_repos
|
||||
from pkgmgr.actions.repository.update import update_repos
|
||||
from pkgmgr.actions.repository.status import status_repos
|
||||
from pkgmgr.actions.repository.list import list_repositories
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.actions.repository.create import create_repo
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
@@ -5,9 +5,9 @@ import os
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.run_command import run_command
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
@@ -4,12 +4,12 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.git_utils import get_tags
|
||||
from pkgmgr.versioning import SemVer, find_latest_version
|
||||
from pkgmgr.version_sources import (
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.version.semver import SemVer, find_latest_version
|
||||
from pkgmgr.core.version.source import (
|
||||
read_pyproject_version,
|
||||
read_flake_version,
|
||||
read_pkgbuild_version,
|
||||
@@ -7,12 +7,12 @@ import os
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.cli_core.proxy import maybe_handle_proxy
|
||||
from pkgmgr.get_selected_repos import get_selected_repos
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.cli.proxy import maybe_handle_proxy
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
from pkgmgr.cli_core.commands import (
|
||||
from pkgmgr.cli.commands import (
|
||||
handle_repos_command,
|
||||
handle_tools_command,
|
||||
handle_release,
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pkgmgr.cli_core.proxy import register_proxy_commands
|
||||
from pkgmgr.cli.proxy import register_proxy_commands
|
||||
|
||||
|
||||
class SortedSubParsersAction(argparse._SubParsersAction):
|
||||
@@ -8,12 +8,12 @@ import os
|
||||
import sys
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from pkgmgr.cli_core.context import CLIContext
|
||||
from pkgmgr.clone_repos import clone_repos
|
||||
from pkgmgr.exec_proxy_command import exec_proxy_command
|
||||
from pkgmgr.pull_with_verification import pull_with_verification
|
||||
from pkgmgr.get_selected_repos import get_selected_repos
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
PROXY_COMMANDS: Dict[str, List[str]] = {
|
||||
@@ -1,5 +0,0 @@
|
||||
from .context import CLIContext
|
||||
from .parser import create_parser
|
||||
from .dispatch import dispatch_command
|
||||
|
||||
__all__ = ["CLIContext", "create_parser", "dispatch_command"]
|
||||
@@ -2,8 +2,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
|
||||
0
pkgmgr/core/config/__init__.py
Normal file
0
pkgmgr/core/config/__init__.py
Normal file
0
pkgmgr/core/repository/__init__.py
Normal file
0
pkgmgr/core/repository/__init__.py
Normal file
@@ -7,7 +7,8 @@ import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Sequence
|
||||
|
||||
from pkgmgr.resolve_repos import resolve_repos
|
||||
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 []
|
||||
0
pkgmgr/core/version/__init__.py
Normal file
0
pkgmgr/core/version/__init__.py
Normal file
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
This exposes all installer classes so users can import them directly from
|
||||
pkgmgr.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
||||
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
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`
|
||||
|
||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
class RpmSpecInstaller(BaseInstaller):
|
||||
"""
|
||||
Build and install RPM-based packages from *.spec files.
|
||||
|
||||
This installer is responsible for the full build + install of the
|
||||
application on RPM-like systems.
|
||||
"""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "os-packages"
|
||||
|
||||
def _is_rpm_like(self) -> bool:
|
||||
"""
|
||||
Basic RPM-like detection:
|
||||
|
||||
- rpmbuild must be available
|
||||
- at least one of dnf / yum / yum-builddep must be present
|
||||
"""
|
||||
if shutil.which("rpmbuild") is None:
|
||||
return False
|
||||
|
||||
has_dnf = shutil.which("dnf") is not None
|
||||
has_yum = shutil.which("yum") is not None
|
||||
has_yum_builddep = shutil.which("yum-builddep") is not None
|
||||
|
||||
return has_dnf or has_yum or has_yum_builddep
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
"""Return the first *.spec file in the repository root, if any."""
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
This installer is supported if:
|
||||
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
|
||||
- a *.spec file exists in the repository root.
|
||||
"""
|
||||
if not self._is_rpm_like():
|
||||
return False
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _find_built_rpms(self) -> List[str]:
|
||||
"""
|
||||
Find RPMs built by rpmbuild.
|
||||
|
||||
By default, rpmbuild outputs RPMs into:
|
||||
~/rpmbuild/RPMS/*/*.rpm
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
pattern = os.path.join(home, "rpmbuild", "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)
|
||||
|
||||
if shutil.which("dnf") is not None:
|
||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||
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(
|
||||
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
|
||||
"Skipping automatic build dependency installation for RPM."
|
||||
)
|
||||
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:
|
||||
"""
|
||||
Build and install RPM-based packages.
|
||||
|
||||
Steps:
|
||||
1. dnf/yum builddep <spec> (automatic build dependency installation)
|
||||
2. rpmbuild -ba path/to/spec
|
||||
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
|
||||
"""
|
||||
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. "
|
||||
"Skipping RPM package installation."
|
||||
)
|
||||
return
|
||||
|
||||
# 4) Install RPMs
|
||||
if shutil.which("rpm") is None:
|
||||
print(
|
||||
"[Warning] rpm binary not found on PATH. "
|
||||
"Cannot install built RPMs."
|
||||
)
|
||||
return
|
||||
|
||||
install_cmd = "sudo rpm -i " + " ".join(rpms)
|
||||
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Python projects based on pyproject.toml.
|
||||
|
||||
Strategy:
|
||||
- Determine a pip command in this order:
|
||||
1. $PKGMGR_PIP (explicit override, e.g. ~/.venvs/pkgmgr/bin/pip)
|
||||
2. sys.executable -m pip (current interpreter)
|
||||
3. "pip" from PATH as last resort
|
||||
- If pyproject.toml exists: pip install .
|
||||
|
||||
All installation failures are treated as fatal errors (SystemExit).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
class PythonInstaller(BaseInstaller):
|
||||
"""Install Python projects and dependencies via pip."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "python"
|
||||
|
||||
def supports(self, ctx) -> 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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
if sys.executable:
|
||||
return f"{sys.executable} -m pip"
|
||||
|
||||
return "pip"
|
||||
|
||||
def run(self, ctx) -> None:
|
||||
"""
|
||||
Install Python project defined via pyproject.toml.
|
||||
|
||||
Any pip failure is propagated as SystemExit.
|
||||
"""
|
||||
pip_cmd = self._pip_cmd()
|
||||
|
||||
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")
|
||||
if os.path.exists(pyproject):
|
||||
print(
|
||||
f"pyproject.toml found in {ctx.identifier}, "
|
||||
f"installing Python project..."
|
||||
)
|
||||
cmd = f"{pip_cmd} install ."
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,48 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||
from pkgmgr.get_repo_dir import get_repo_dir
|
||||
from pkgmgr.verify import verify_repository
|
||||
|
||||
def pull_with_verification(
|
||||
selected_repos,
|
||||
repositories_base_dir,
|
||||
all_repos,
|
||||
extra_args,
|
||||
no_verification,
|
||||
preview:bool):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
if 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}")
|
||||
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
|
||||
if choice != "y":
|
||||
continue
|
||||
|
||||
full_cmd = f"git pull {' '.join(extra_args)}"
|
||||
if preview:
|
||||
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}.")
|
||||
sys.exit(result.returncode)
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "package-manager"
|
||||
version = "0.6.0"
|
||||
version = "0.7.11"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,6 +13,7 @@ dnf -y install \
|
||||
bash \
|
||||
curl-minimal \
|
||||
ca-certificates \
|
||||
sudo \
|
||||
xz
|
||||
|
||||
dnf clean all
|
||||
|
||||
@@ -19,6 +19,7 @@ for distro in $DISTROS; do
|
||||
# Run the command and capture the output
|
||||
if OUTPUT=$(docker run --rm \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-v pkgmgr_nix_store:/nix \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
"$IMAGE" 2>&1); then
|
||||
|
||||
@@ -8,16 +8,12 @@ for distro in $DISTROS; do
|
||||
echo ">>> Running E2E tests: $distro"
|
||||
echo "============================================================"
|
||||
|
||||
MOUNT_NIX=""
|
||||
if [[ "$distro" == "arch" ]]; then
|
||||
MOUNT_NIX="-v pkgmgr_nix_store:/nix"
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
$MOUNT_NIX \
|
||||
-v pkgmgr_nix_store:/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 +47,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
|
||||
|
||||
@@ -7,9 +7,11 @@ echo "============================================================"
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v pkgmgr_nix_store:/nix \
|
||||
-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 +21,5 @@ docker run --rm \
|
||||
python -m unittest discover \
|
||||
-s tests/integration \
|
||||
-t /src \
|
||||
-p "test_*.py";
|
||||
-p "$TEST_PATTERN";
|
||||
'
|
||||
|
||||
@@ -8,8 +8,10 @@ echo "============================================================"
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
-v pkgmgr_nix_store:/nix \
|
||||
--workdir /src \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
--entrypoint bash \
|
||||
"package-manager-test-arch" \
|
||||
-c '
|
||||
@@ -19,5 +21,5 @@ docker run --rm \
|
||||
python -m unittest discover \
|
||||
-s tests/unit \
|
||||
-t /src \
|
||||
-p "test_*.py";
|
||||
-p "$TEST_PATTERN";
|
||||
'
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.branch.open_branch")
|
||||
@patch("pkgmgr.cli.commands.branch.open_branch")
|
||||
def test_branch_open_with_name_and_base(self, mock_open_branch) -> None:
|
||||
"""
|
||||
`pkgmgr branch open feature/test --base develop` must forward
|
||||
@@ -47,7 +47,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
|
||||
self.assertEqual(kwargs.get("base_branch"), "develop")
|
||||
self.assertEqual(kwargs.get("cwd"), ".")
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.branch.open_branch")
|
||||
@patch("pkgmgr.cli.commands.branch.open_branch")
|
||||
def test_branch_open_without_name_uses_default_base(
|
||||
self,
|
||||
mock_open_branch,
|
||||
@@ -68,7 +68,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
|
||||
# close subcommand
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.branch.close_branch")
|
||||
@patch("pkgmgr.cli.commands.branch.close_branch")
|
||||
def test_branch_close_with_name_and_base(self, mock_close_branch) -> None:
|
||||
"""
|
||||
`pkgmgr branch close feature/test --base develop` must forward
|
||||
@@ -84,7 +84,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
|
||||
self.assertEqual(kwargs.get("base_branch"), "develop")
|
||||
self.assertEqual(kwargs.get("cwd"), ".")
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.branch.close_branch")
|
||||
@patch("pkgmgr.cli.commands.branch.close_branch")
|
||||
def test_branch_close_without_name_uses_default_base(
|
||||
self,
|
||||
mock_close_branch,
|
||||
@@ -1,4 +1,3 @@
|
||||
# tests/e2e/test_integration_changelog_commands.py
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -6,7 +5,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import (
|
||||
from test_version_commands import (
|
||||
_load_pkgmgr_repo_dir,
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
@@ -54,7 +53,7 @@ class TestIntegrationChangelogCommands(unittest.TestCase):
|
||||
sys.argv = ["pkgmgr", "changelog"] + list(extra_args)
|
||||
|
||||
try:
|
||||
runpy.run_module("pkgmgr.cli", run_name="__main__")
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
if code != 0:
|
||||
@@ -14,7 +14,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_install_pkgmgr_shallow import (
|
||||
from test_install_pkgmgr_shallow import (
|
||||
nix_profile_list_debug,
|
||||
remove_pkgmgr_from_nix_profile,
|
||||
pkgmgr_help_debug,
|
||||
@@ -35,8 +35,8 @@ 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`)
|
||||
- 'package-manager' (the name mentioned in Nix's own error hints)
|
||||
- 'pkgmgr'
|
||||
- 'package-manager'
|
||||
"""
|
||||
for spec in ("pkgmgr", "package-manager"):
|
||||
subprocess.run(
|
||||
@@ -45,18 +45,34 @@ def remove_pkgmgr_from_nix_profile() -> None:
|
||||
)
|
||||
|
||||
|
||||
def configure_git_safe_directory() -> None:
|
||||
"""
|
||||
Configure Git to treat /src as a safe directory.
|
||||
|
||||
Needed because /src is a bind-mounted repository in CI, often owned by a
|
||||
different UID. Modern Git aborts with:
|
||||
'fatal: detected dubious ownership in repository at /src/.git'
|
||||
|
||||
This fix applies ONLY inside this test container.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "config", "--global", "--add", "safe.directory", "/src"],
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[WARN] git not found – skipping safe.directory configuration")
|
||||
|
||||
|
||||
def pkgmgr_help_debug() -> None:
|
||||
"""
|
||||
Run `pkgmgr --help` after installation *inside an interactive bash shell*,
|
||||
print its output and return code, but never fail the test.
|
||||
|
||||
Reason:
|
||||
- The installer adds venv/alias setup into shell rc files (~/.bashrc, ~/.zshrc)
|
||||
- Those changes are only applied in a new interactive shell session.
|
||||
This ensures the installer’s shell RC changes are actually loaded.
|
||||
"""
|
||||
print("\n--- PKGMGR HELP (after installation, via bash -i) ---")
|
||||
|
||||
# Simulate a fresh interactive bash, so ~/.bashrc gets sourced
|
||||
proc = subprocess.run(
|
||||
["bash", "-i", "-c", "pkgmgr --help"],
|
||||
capture_output=True,
|
||||
@@ -76,29 +92,43 @@ 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.
|
||||
|
||||
|
||||
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")
|
||||
HOME is isolated to avoid permission problems with Nix & repositories.
|
||||
"""
|
||||
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 XDG override for a fully isolated environment
|
||||
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"))
|
||||
|
||||
# 🔧 IMPORTANT FIX: allow Git to access /src safely
|
||||
configure_git_safe_directory()
|
||||
|
||||
# Debug before cleanup
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
|
||||
# Cleanup: drop any pkgmgr entries from nix profile
|
||||
remove_pkgmgr_from_nix_profile()
|
||||
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
|
||||
# Prepare argv for module execution
|
||||
sys.argv = [
|
||||
"python",
|
||||
"install",
|
||||
@@ -107,13 +137,18 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
|
||||
"shallow",
|
||||
"--no-verification",
|
||||
]
|
||||
# Führt die Installation via main.py aus
|
||||
|
||||
# Execute installation via main.py
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
|
||||
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
|
||||
# Debug: interactive shell test
|
||||
pkgmgr_help_debug()
|
||||
|
||||
finally:
|
||||
# Restore system state
|
||||
sys.argv = original_argv
|
||||
os.environ.clear()
|
||||
os.environ.update(original_environ)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -5,7 +5,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import PROJECT_ROOT
|
||||
from test_version_commands import PROJECT_ROOT
|
||||
|
||||
|
||||
class TestIntegrationListCommands(unittest.TestCase):
|
||||
@@ -15,7 +15,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import _load_pkgmgr_repo_dir
|
||||
from test_version_commands import _load_pkgmgr_repo_dir
|
||||
|
||||
|
||||
class TestIntegrationMakeCommands(unittest.TestCase):
|
||||
@@ -5,7 +5,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import PROJECT_ROOT
|
||||
from test_version_commands import PROJECT_ROOT
|
||||
|
||||
|
||||
class TestIntegrationProxyCommands(unittest.TestCase):
|
||||
@@ -6,7 +6,7 @@ End-to-end style integration tests for the `pkgmgr release` CLI command.
|
||||
|
||||
These tests exercise the real top-level entry point (main.py) and mock
|
||||
the high-level helper used by the CLI wiring
|
||||
(pkgmgr.cli_core.commands.release.run_release) to ensure that argument
|
||||
(pkgmgr.cli.commands.release.run_release) to ensure that argument
|
||||
parsing and dispatch behave as expected, in particular the new `close`
|
||||
flag.
|
||||
|
||||
@@ -52,8 +52,8 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
|
||||
# Behaviour without --close
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.release.run_release")
|
||||
@patch("pkgmgr.cli_core.dispatch._select_repo_for_current_directory")
|
||||
@patch("pkgmgr.cli.commands.release.run_release")
|
||||
@patch("pkgmgr.cli.dispatch._select_repo_for_current_directory")
|
||||
def test_release_without_close_flag(
|
||||
self,
|
||||
mock_select_repo,
|
||||
@@ -95,8 +95,8 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
|
||||
# Behaviour with --close
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@patch("pkgmgr.cli_core.commands.release.run_release")
|
||||
@patch("pkgmgr.cli_core.dispatch._select_repo_for_current_directory")
|
||||
@patch("pkgmgr.cli.commands.release.run_release")
|
||||
@patch("pkgmgr.cli.dispatch._select_repo_for_current_directory")
|
||||
def test_release_with_close_flag(
|
||||
self,
|
||||
mock_select_repo,
|
||||
@@ -133,6 +133,36 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
|
||||
"close must be True when --close is given",
|
||||
)
|
||||
|
||||
def test_release_help_runs_without_error(self) -> None:
|
||||
"""
|
||||
Running `pkgmgr release --help` should succeed without touching the
|
||||
release helper and print a usage message for the release subcommand.
|
||||
|
||||
This test intentionally does not mock anything to exercise the real
|
||||
CLI parser wiring in main.py.
|
||||
"""
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
original_argv = list(sys.argv)
|
||||
buf = io.StringIO()
|
||||
|
||||
try:
|
||||
sys.argv = ["pkgmgr", "release", "--help"]
|
||||
# argparse will call sys.exit(), so we expect a SystemExit here.
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
# Help exit code is usually 0 (or sometimes None, which also means "no error")
|
||||
self.assertIn(cm.exception.code, (0, None))
|
||||
|
||||
output = buf.getvalue()
|
||||
# Sanity checks: release help text should be present
|
||||
self.assertIn("usage:", output)
|
||||
self.assertIn("pkgmgr release", output)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -24,7 +24,7 @@ import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import _load_pkgmgr_repo_dir
|
||||
from test_version_commands import _load_pkgmgr_repo_dir
|
||||
|
||||
|
||||
@unittest.skip(
|
||||
@@ -28,7 +28,7 @@ import sys
|
||||
import unittest
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.load_config import load_config
|
||||
from pkgmgr.core.config.load import load_config
|
||||
|
||||
# Resolve project root (the repo where main.py lives, e.g. /src)
|
||||
PROJECT_ROOT = os.path.abspath(
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user