Compare commits
46
Commits
v1.8.1
...
b20882f492
No files matched your search
@@ -6,6 +6,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-unit:
|
test-unit:
|
||||||
uses: ./.github/workflows/test-unit.yml
|
uses: ./.github/workflows/test-unit.yml
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
name: Mark stable commit
|
name: Mark stable commit
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mark-${{ github.repository }}-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main # still run tests for main
|
- main # still run tests for main
|
||||||
tags:
|
|
||||||
- 'v*' # run tests for version tags (e.g. v0.9.1)
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-unit:
|
test-unit:
|
||||||
uses: ./.github/workflows/test-unit.yml
|
uses: ./.github/workflows/test-unit.yml
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
distro: [arch, debian, ubuntu, fedora, centos]
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
distro: [arch, debian, ubuntu, fedora, centos]
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
distro: [arch, debian, ubuntu, fedora, centos]
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ jobs:
|
|||||||
test-unit:
|
test-unit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
distro: [arch, debian, ubuntu, fedora, centos]
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -19,27 +21,26 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
# 🔹 BUILD virgin image if missing
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Build virgin container (${{ matrix.distro }})
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
||||||
|
|
||||||
# 🔹 RUN test inside virgin image
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$PWD":/src \
|
-v "$PWD":/opt/src/pkgmgr \
|
||||||
-v pkgmgr_repos:/root/Repositories \
|
-v pkgmgr_repos:/root/Repositories \
|
||||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||||
-w /src \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
git config --global --add safe.directory /src
|
git config --global --add safe.directory /opt/src/pkgmgr
|
||||||
|
|
||||||
make install
|
make install
|
||||||
make setup
|
make setup
|
||||||
@@ -50,5 +51,5 @@ jobs:
|
|||||||
pkgmgr version pkgmgr
|
pkgmgr version pkgmgr
|
||||||
|
|
||||||
echo ">>> Running Nix-based: nix run .#pkgmgr -- version pkgmgr"
|
echo ">>> Running Nix-based: nix run .#pkgmgr -- version pkgmgr"
|
||||||
nix run /src#pkgmgr -- version pkgmgr
|
nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
|
||||||
'
|
'
|
||||||
@@ -11,7 +11,9 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
distro: [arch, debian, ubuntu, fedora, centos]
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -19,20 +21,19 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
# 🔹 BUILD virgin image if missing
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Build virgin container (${{ matrix.distro }})
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
||||||
|
|
||||||
# 🔹 RUN test inside virgin image as non-root
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$PWD":/src \
|
-v "$PWD":/opt/src/pkgmgr \
|
||||||
-w /src \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"pkgmgr-${{ matrix.distro }}-virgin" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -42,23 +43,25 @@ jobs:
|
|||||||
useradd -m dev
|
useradd -m dev
|
||||||
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
||||||
chmod 0440 /etc/sudoers.d/dev
|
chmod 0440 /etc/sudoers.d/dev
|
||||||
chown -R dev:dev /src
|
chown -R dev:dev /opt/src/pkgmgr
|
||||||
|
|
||||||
mkdir -p /nix/store /nix/var/nix /nix/var/log/nix /nix/var/nix/profiles
|
mkdir -p /nix/store /nix/var/nix /nix/var/log/nix /nix/var/nix/profiles
|
||||||
chown -R dev:dev /nix
|
chown -R dev:dev /nix
|
||||||
chmod 0755 /nix
|
chmod 0755 /nix
|
||||||
chmod 1777 /nix/store
|
chmod 1777 /nix/store
|
||||||
|
sudo -H -u dev env \
|
||||||
|
HOME=/home/dev \
|
||||||
|
NIX_CONFIG="$NIX_CONFIG" \
|
||||||
|
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 \
|
||||||
|
bash -lc "
|
||||||
|
set -euo pipefail
|
||||||
|
cd /opt/src/pkgmgr
|
||||||
|
make setup-venv
|
||||||
|
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
|
||||||
|
|
||||||
sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
|
pkgmgr version pkgmgr
|
||||||
set -euo pipefail
|
|
||||||
cd /src
|
|
||||||
|
|
||||||
make setup-venv
|
export NIX_REMOTE=local
|
||||||
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
|
nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
|
||||||
|
"
|
||||||
pkgmgr version pkgmgr
|
|
||||||
|
|
||||||
export NIX_REMOTE=local
|
|
||||||
nix run /src#pkgmgr -- version pkgmgr
|
|
||||||
"
|
|
||||||
'
|
'
|
||||||
+100
@@ -1,3 +1,103 @@
|
|||||||
|
## [1.12.1] - 2026-02-14
|
||||||
|
|
||||||
|
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-02-08
|
||||||
|
|
||||||
|
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.2] - 2026-02-08
|
||||||
|
|
||||||
|
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.1] - 2026-02-08
|
||||||
|
|
||||||
|
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-01-21
|
||||||
|
|
||||||
|
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-01-20
|
||||||
|
|
||||||
|
* Introduce safe verbose image cleanup to reduce Docker image size and build artifacts
|
||||||
|
|
||||||
|
## [1.9.5] - 2026-01-16
|
||||||
|
|
||||||
|
* Release patch: improve git pull error diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-01-13
|
||||||
|
|
||||||
|
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-01-07
|
||||||
|
|
||||||
|
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.2] - 2025-12-21
|
||||||
|
|
||||||
|
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.1] - 2025-12-21
|
||||||
|
|
||||||
|
* Fixed installation issues and improved loading of default configuration files.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.0] - 2025-12-20
|
||||||
|
|
||||||
|
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||||
|
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
|
||||||
|
* All configured git mirrors are now provisioned.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.7] - 2025-12-19
|
||||||
|
|
||||||
|
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
|
||||||
|
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
|
||||||
|
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.6] - 2025-12-17
|
||||||
|
|
||||||
|
* Prevent Rate Limits during GitHub Nix Setups
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.5] - 2025-12-17
|
||||||
|
|
||||||
|
* * Clearer Git error handling, especially when a directory is not a Git repository.
|
||||||
|
* More reliable repository verification with improved commit and GPG signature checks.
|
||||||
|
* Better error messages and overall robustness when working with Git-based workflows.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.0] - 2025-12-17
|
||||||
|
|
||||||
|
* Automated release.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.4] - 2025-12-17
|
||||||
|
|
||||||
|
* * Made pkgmgr’s base-layer role explicit by standardizing the Docker/CI mount path to *`/opt/src/pkgmgr`*.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.3] - 2025-12-16
|
||||||
|
|
||||||
|
* MIRRORS now supports plain URL entries, ensuring metadata-only sources like PyPI are recorded without ever being added to the Git configuration.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.8.2] - 2025-12-16
|
||||||
|
|
||||||
|
* * ***pkgmgr tools code*** is more robust and predictable: it now fails early with clear errors if VS Code is not installed or a repository is not yet identified.
|
||||||
|
|
||||||
|
|
||||||
## [1.8.1] - 2025-12-16
|
## [1.8.1] - 2025-12-16
|
||||||
|
|
||||||
* * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
* * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
||||||
|
|||||||
+14
-1
@@ -33,6 +33,7 @@ CMD ["bash"]
|
|||||||
# - inherits from virgin
|
# - inherits from virgin
|
||||||
# - builds + installs pkgmgr
|
# - builds + installs pkgmgr
|
||||||
# - sets entrypoint + default cmd
|
# - sets entrypoint + default cmd
|
||||||
|
# - NOTE: does NOT run slim.sh (that is done in slim stage)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM virgin AS full
|
FROM virgin AS full
|
||||||
|
|
||||||
@@ -50,6 +51,18 @@ RUN set -euo pipefail; \
|
|||||||
# Entry point
|
# Entry point
|
||||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /opt/src/pkgmgr
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||||
CMD ["pkgmgr", "--help"]
|
CMD ["pkgmgr", "--help"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Target: slim
|
||||||
|
# - based on full
|
||||||
|
# - runs slim.sh
|
||||||
|
# ============================================================
|
||||||
|
FROM full AS slim
|
||||||
|
|
||||||
|
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
||||||
|
RUN chmod +x /usr/local/bin/slim.sh
|
||||||
|
RUN /usr/local/bin/slim.sh
|
||||||
@@ -10,6 +10,10 @@ DISTROS ?= arch debian ubuntu fedora centos
|
|||||||
PKGMGR_DISTRO ?= arch
|
PKGMGR_DISTRO ?= arch
|
||||||
export PKGMGR_DISTRO
|
export PKGMGR_DISTRO
|
||||||
|
|
||||||
|
# Nix Config Variable (To avoid rate limit)
|
||||||
|
NIX_CONFIG ?=
|
||||||
|
export NIX_CONFIG
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Base images
|
# Base images
|
||||||
# (kept for documentation/reference; actual build logic is in scripts/build)
|
# (kept for documentation/reference; actual build logic is in scripts/build)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.8.1";
|
version = "1.12.1";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
+13
-3
@@ -1,15 +1,25 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=1.8.1
|
pkgver=1.12.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://github.com/kevinveenbirkenbach/package-manager"
|
url="https://github.com/kevinveenbirkenbach/package-manager"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
|
|
||||||
# Nix is the only runtime dependency; Python is provided by the Nix closure.
|
# Nix is required at runtime to run pkgmgr via the flake.
|
||||||
depends=('nix')
|
# On Arch x86_64 we can depend on the distro package.
|
||||||
|
# On other arches (e.g. ARM) we only declare it as optional because the
|
||||||
|
# repo package may be broken/out-of-sync; installation can be done via the official installer.
|
||||||
|
depends=()
|
||||||
|
optdepends=('nix: required to run pkgmgr via flake')
|
||||||
|
|
||||||
|
if [[ "${CARCH}" == "x86_64" ]]; then
|
||||||
|
depends=('nix')
|
||||||
|
optdepends=()
|
||||||
|
fi
|
||||||
|
|
||||||
makedepends=('rsync')
|
makedepends=('rsync')
|
||||||
|
|
||||||
install=${pkgname}.install
|
install=${pkgname}.install
|
||||||
|
|||||||
@@ -1,3 +1,123 @@
|
|||||||
|
package-manager (1.12.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 14 Feb 2026 23:26:17 +0100
|
||||||
|
|
||||||
|
package-manager (1.12.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:26:25 +0100
|
||||||
|
|
||||||
|
package-manager (1.11.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:21:50 +0100
|
||||||
|
|
||||||
|
package-manager (1.11.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:18:09 +0100
|
||||||
|
|
||||||
|
package-manager (1.11.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 21 Jan 2026 01:18:31 +0100
|
||||||
|
|
||||||
|
package-manager (1.10.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Automated release.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 20 Jan 2026 10:44:58 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Release patch: improve git pull error diagnostics
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 16 Jan 2026 10:09:43 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 13 Jan 2026 14:48:50 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 07 Jan 2026 13:44:40 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 15:30:22 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Fixed installation issues and improved loading of default configuration files.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 13:38:58 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||||
|
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
|
||||||
|
* All configured git mirrors are now provisioned.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 20 Dec 2025 14:37:58 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.7-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
|
||||||
|
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
|
||||||
|
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 19 Dec 2025 14:15:47 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.6-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Prevent Rate Limits during GitHub Nix Setups
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 23:50:31 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* * Clearer Git error handling, especially when a directory is not a Git repository.
|
||||||
|
* More reliable repository verification with improved commit and GPG signature checks.
|
||||||
|
* Better error messages and overall robustness when working with Git-based workflows.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 22:15:48 +0100
|
||||||
|
|
||||||
|
package-manager (1.9.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Automated release.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 22:10:31 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* * Made pkgmgr’s base-layer role explicit by standardizing the Docker/CI mount path to *`/opt/src/pkgmgr`*.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 11:20:16 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* MIRRORS now supports plain URL entries, ensuring metadata-only sources like PyPI are recorded without ever being added to the Git configuration.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 16 Dec 2025 19:49:51 +0100
|
||||||
|
|
||||||
|
package-manager (1.8.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* * ***pkgmgr tools code*** is more robust and predictable: it now fails early with clear errors if VS Code is not installed or a repository is not yet identified.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 16 Dec 2025 19:22:41 +0100
|
||||||
|
|
||||||
package-manager (1.8.1-1) unstable; urgency=medium
|
package-manager (1.8.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
* * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
* * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 1.8.1
|
Version: 1.12.1
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||||
|
|
||||||
@@ -74,6 +74,69 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
|||||||
/usr/lib/package-manager/
|
/usr/lib/package-manager/
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Sat Feb 14 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.1-1
|
||||||
|
- pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.0-1
|
||||||
|
- Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.2-1
|
||||||
|
- Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.1-1
|
||||||
|
- Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
|
||||||
|
|
||||||
|
* Wed Jan 21 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.0-1
|
||||||
|
- Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
|
||||||
|
|
||||||
|
* Tue Jan 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.10.0-1
|
||||||
|
- Automated release.
|
||||||
|
|
||||||
|
* Fri Jan 16 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.5-1
|
||||||
|
- Release patch: improve git pull error diagnostics
|
||||||
|
|
||||||
|
* Tue Jan 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.4-1
|
||||||
|
- fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
|
||||||
|
|
||||||
|
* Wed Jan 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.3-1
|
||||||
|
- Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
|
||||||
|
|
||||||
|
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.2-1
|
||||||
|
- Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||||
|
|
||||||
|
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.1-1
|
||||||
|
- Fixed installation issues and improved loading of default configuration files.
|
||||||
|
|
||||||
|
* Sat Dec 20 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
|
||||||
|
- * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||||
|
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
|
||||||
|
* All configured git mirrors are now provisioned.
|
||||||
|
|
||||||
|
* Fri Dec 19 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.7-1
|
||||||
|
- * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
|
||||||
|
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
|
||||||
|
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
|
||||||
|
|
||||||
|
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.6-1
|
||||||
|
- Prevent Rate Limits during GitHub Nix Setups
|
||||||
|
|
||||||
|
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.5-1
|
||||||
|
- * Clearer Git error handling, especially when a directory is not a Git repository.
|
||||||
|
* More reliable repository verification with improved commit and GPG signature checks.
|
||||||
|
* Better error messages and overall robustness when working with Git-based workflows.
|
||||||
|
|
||||||
|
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
|
||||||
|
- Automated release.
|
||||||
|
|
||||||
|
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.4-1
|
||||||
|
- * Made pkgmgr’s base-layer role explicit by standardizing the Docker/CI mount path to *`/opt/src/pkgmgr`*.
|
||||||
|
|
||||||
|
* Tue Dec 16 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.3-1
|
||||||
|
- MIRRORS now supports plain URL entries, ensuring metadata-only sources like PyPI are recorded without ever being added to the Git configuration.
|
||||||
|
|
||||||
|
* Tue Dec 16 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.2-1
|
||||||
|
- * ***pkgmgr tools code*** is more robust and predictable: it now fails early with clear errors if VS Code is not installed or a repository is not yet identified.
|
||||||
|
|
||||||
* Tue Dec 16 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.1-1
|
* Tue Dec 16 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.1-1
|
||||||
- * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
- * Improved stability and consistency of all Git operations (clone, pull, push, release, branch handling) with clearer error messages and predictable preview behavior.
|
||||||
* Mirrors are now handled cleanly: only valid Git remotes are used for Git operations, while non-Git URLs (e.g. PyPI) are excluded, preventing broken or confusing repository configs.
|
* Mirrors are now handled cleanly: only valid Git remotes are used for Git operations, while non-Git URLs (e.g. PyPI) are excluded, preventing broken or confusing repository configs.
|
||||||
|
|||||||
+6
-5
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.8.1"
|
version = "1.12.1"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -43,11 +43,12 @@ pkgmgr = "pkgmgr.cli:main"
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Source layout: all packages live under "src/"
|
# Source layout: all packages live under "src/"
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = { "" = "src", "config" = "config" }
|
package-dir = { "" = "src" }
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src", "."]
|
where = ["src"]
|
||||||
include = ["pkgmgr*", "config*"]
|
include = ["pkgmgr*"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"config" = ["defaults.yaml"]
|
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||||
@@ -33,7 +33,7 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
|||||||
Build options:
|
Build options:
|
||||||
--missing Build only if the image does not already exist (local build only)
|
--missing Build only if the image does not already exist (local build only)
|
||||||
--no-cache Build with --no-cache
|
--no-cache Build with --no-cache
|
||||||
--target <name> Build a specific Dockerfile target (e.g. virgin)
|
--target <name> Build a specific Dockerfile target (e.g. virgin, slim)
|
||||||
--tag <image> Override the output image tag (default: ${default_tag})
|
--tag <image> Override the output image tag (default: ${default_tag})
|
||||||
|
|
||||||
Publish options:
|
Publish options:
|
||||||
@@ -47,7 +47,7 @@ Publish options:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- --publish implies --push and requires --registry, --owner, and --version.
|
- --publish implies --push and requires --registry, --owner, and --version.
|
||||||
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin".
|
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--missing) MISSING_ONLY=1; shift ;;
|
--missing) MISSING_ONLY=1; shift ;;
|
||||||
--target)
|
--target)
|
||||||
TARGET="${2:-}"
|
TARGET="${2:-}"
|
||||||
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin)"; exit 2; }
|
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--tag)
|
--tag)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Publish all distro images (full + virgin) to a registry via image.sh --publish
|
# Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
|
||||||
#
|
#
|
||||||
# Required env:
|
# Required env:
|
||||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||||
@@ -11,6 +11,9 @@ set -euo pipefail
|
|||||||
# REGISTRY (default: ghcr.io)
|
# REGISTRY (default: ghcr.io)
|
||||||
# IS_STABLE (default: false)
|
# IS_STABLE (default: false)
|
||||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - This expects Dockerfile targets: virgin, full (default), slim
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
@@ -33,7 +36,10 @@ for d in ${DISTROS}; do
|
|||||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
# virgin
|
# virgin
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
|
||||||
|
# ----------------------------------------------------------
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
@@ -42,13 +48,29 @@ for d in ${DISTROS}; do
|
|||||||
--stable "${IS_STABLE}" \
|
--stable "${IS_STABLE}" \
|
||||||
--target virgin
|
--target virgin
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
# full (default target)
|
# full (default target)
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
|
||||||
|
# ----------------------------------------------------------
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
--owner "${OWNER}" \
|
--owner "${OWNER}" \
|
||||||
--version "${VERSION}" \
|
--version "${VERSION}" \
|
||||||
--stable "${IS_STABLE}"
|
--stable "${IS_STABLE}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# slim
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
|
||||||
|
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
|
--publish \
|
||||||
|
--registry "${REGISTRY}" \
|
||||||
|
--owner "${OWNER}" \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--stable "${IS_STABLE}" \
|
||||||
|
--target slim
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "[docker] Starting package-manager container"
|
echo "[docker-pkgmgr] Starting package-manager container"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Log distribution info
|
# Log distribution info
|
||||||
@@ -9,19 +9,19 @@ echo "[docker] Starting package-manager container"
|
|||||||
if [[ -f /etc/os-release ]]; then
|
if [[ -f /etc/os-release ]]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
echo "[docker] Detected distro: ${ID:-unknown} (like: ${ID_LIKE:-})"
|
echo "[docker-pkgmgr] Detected distro: ${ID:-unknown} (like: ${ID_LIKE:-})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Always use /src (mounted from host) as working directory
|
# Always use /opt/src/pkgmgr (mounted from host) as working directory
|
||||||
echo "[docker] Using /src as working directory"
|
echo "[docker-pkgmgr] Using /opt/src/pkgmgr as working directory"
|
||||||
cd /src
|
cd /opt/src/pkgmgr
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DEV mode: rebuild package-manager from the mounted /src tree
|
# DEV mode: rebuild package-manager from the mounted /opt/src/pkgmgr tree
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ "${REINSTALL_PKGMGR:-0}" == "1" ]]; then
|
if [[ "${REINSTALL_PKGMGR:-0}" == "1" ]]; then
|
||||||
echo "[docker] DEV mode enabled (REINSTALL_PKGMGR=1)"
|
echo "[docker-pkgmgr] DEV mode enabled (REINSTALL_PKGMGR=1)"
|
||||||
echo "[docker] Rebuilding package-manager from /src via scripts/installation/package.sh..."
|
echo "[docker-pkgmgr] Rebuilding package-manager from /opt/src/pkgmgr via scripts/installation/package.sh..."
|
||||||
bash scripts/installation/package.sh || exit 1
|
bash scripts/installation/package.sh || exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ fi
|
|||||||
# Hand off to pkgmgr or arbitrary command
|
# Hand off to pkgmgr or arbitrary command
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ $# -eq 0 ]]; then
|
if [[ $# -eq 0 ]]; then
|
||||||
echo "[docker] No arguments provided. Showing pkgmgr help..."
|
echo "[docker-pkgmgr] No arguments provided. Showing pkgmgr help..."
|
||||||
exec pkgmgr --help
|
exec pkgmgr --help
|
||||||
else
|
else
|
||||||
echo "[docker] Executing command: $*"
|
echo "[docker-pkgmgr] Executing command: $*"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
fi
|
fi
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo "[cleanup] $*"; }
|
||||||
|
warn() { echo "[cleanup][WARN] $*" >&2; }
|
||||||
|
|
||||||
|
MODE="${MODE:-safe}" # safe | aggressive
|
||||||
|
# safe: caches/logs/tmp only
|
||||||
|
# aggressive: safe + docs/man/info (optional)
|
||||||
|
|
||||||
|
ID="unknown"
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
ID="${ID:-unknown}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting image cleanup"
|
||||||
|
log "Mode: ${MODE}"
|
||||||
|
log "Detected OS: ${ID}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Package manager caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
case "${ID}" in
|
||||||
|
alpine)
|
||||||
|
log "Cleaning apk cache"
|
||||||
|
if [ -d /var/cache/apk ]; then
|
||||||
|
du -sh /var/cache/apk || true
|
||||||
|
rm -rvf /var/cache/apk/* || true
|
||||||
|
else
|
||||||
|
log "apk cache directory not present (already clean)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
arch)
|
||||||
|
log "Cleaning pacman cache"
|
||||||
|
du -sh /var/cache/pacman/pkg 2>/dev/null || true
|
||||||
|
pacman -Scc --noconfirm || true
|
||||||
|
rm -rvf /var/cache/pacman/pkg/* || true
|
||||||
|
;;
|
||||||
|
debian|ubuntu)
|
||||||
|
log "Cleaning apt cache"
|
||||||
|
du -sh /var/lib/apt/lists 2>/dev/null || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rvf /var/lib/apt/lists/* || true
|
||||||
|
;;
|
||||||
|
fedora)
|
||||||
|
log "Cleaning dnf cache"
|
||||||
|
du -sh /var/cache/dnf 2>/dev/null || true
|
||||||
|
dnf clean all || true
|
||||||
|
rm -rvf /var/cache/dnf/* || true
|
||||||
|
;;
|
||||||
|
centos|rhel)
|
||||||
|
log "Cleaning yum/dnf cache"
|
||||||
|
du -sh /var/cache/yum /var/cache/dnf 2>/dev/null || true
|
||||||
|
(command -v dnf >/dev/null 2>&1 && dnf clean all) || true
|
||||||
|
(command -v yum >/dev/null 2>&1 && yum clean all) || true
|
||||||
|
rm -rvf /var/cache/yum/* /var/cache/dnf/* || true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Unknown distro '${ID}' — skipping package manager cleanup"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Python caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning pip cache"
|
||||||
|
du -sh /root/.cache/pip 2>/dev/null || true
|
||||||
|
rm -rvf /root/.cache/pip 2>/dev/null || true
|
||||||
|
rm -rvf /home/*/.cache/pip 2>/dev/null || true
|
||||||
|
|
||||||
|
log "Cleaning __pycache__ directories"
|
||||||
|
find /opt /usr /root /home -type d -name "__pycache__" -print -prune 2>/dev/null || true
|
||||||
|
find /opt /usr /root /home -type d -name "__pycache__" -prune -exec rm -rvf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Logs (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Truncating log files (keeping paths intact)"
|
||||||
|
if [ -d /var/log ]; then
|
||||||
|
find /var/log -type f -name "*.log" -print 2>/dev/null || true
|
||||||
|
find /var/log -type f -name "*.log" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
||||||
|
|
||||||
|
find /var/log -type f -name "*.out" -print 2>/dev/null || true
|
||||||
|
find /var/log -type f -name "*.out" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
log "Vacuuming journald logs"
|
||||||
|
journalctl --disk-usage || true
|
||||||
|
journalctl --vacuum-size=10M || true
|
||||||
|
journalctl --vacuum-time=1s || true
|
||||||
|
journalctl --disk-usage || true
|
||||||
|
else
|
||||||
|
log "journald not present (skipping)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Temporary files (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning temporary directories"
|
||||||
|
if [ -d /tmp ]; then
|
||||||
|
du -sh /tmp 2>/dev/null || true
|
||||||
|
rm -rvf /tmp/* || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /var/tmp ]; then
|
||||||
|
du -sh /var/tmp 2>/dev/null || true
|
||||||
|
rm -rvf /var/tmp/* || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Generic caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning generic caches"
|
||||||
|
du -sh /root/.cache 2>/dev/null || true
|
||||||
|
rm -rvf /root/.cache/* 2>/dev/null || true
|
||||||
|
rm -rvf /home/*/.cache/* 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Optional aggressive extras (still safe for runtime)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
if [[ "${MODE}" == "aggressive" ]]; then
|
||||||
|
log "Aggressive mode enabled: removing docs/man/info"
|
||||||
|
du -sh /usr/share/doc /usr/share/man /usr/share/info 2>/dev/null || true
|
||||||
|
rm -rvf /usr/share/doc/* /usr/share/man/* /usr/share/info/* 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Cleanup finished successfully"
|
||||||
@@ -38,11 +38,7 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
|
|||||||
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
||||||
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
||||||
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
RUN_AS_AUR=(su - aur_builder -s /bin/bash -c)
|
||||||
RUN_AS_AUR=(sudo -u aur_builder bash -lc)
|
|
||||||
else
|
|
||||||
RUN_AS_AUR=(su - aur_builder -c)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ echo "[arch/package] Building Arch package (makepkg --nodeps) in an isolated bui
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||||
|
|
||||||
# We must not build inside /src (mounted repo). Build in /tmp to avoid permission issues.
|
# We must not build inside /opt/src/pkgmgr (mounted repo). Build in /tmp to avoid permission issues.
|
||||||
BUILD_ROOT="/tmp/package-manager-arch-build"
|
BUILD_ROOT="/tmp/package-manager-arch-build"
|
||||||
PKG_SRC_DIR="${PROJECT_ROOT}/packaging/arch"
|
PKG_SRC_DIR="${PROJECT_ROOT}/packaging/arch"
|
||||||
PKG_BUILD_DIR="${BUILD_ROOT}/packaging/arch"
|
PKG_BUILD_DIR="${BUILD_ROOT}/packaging/arch"
|
||||||
|
|||||||
@@ -49,11 +49,7 @@ install_nix_with_retry() {
|
|||||||
if [[ -n "$run_as" ]]; then
|
if [[ -n "$run_as" ]]; then
|
||||||
chown "$run_as:$run_as" "$installer" 2>/dev/null || true
|
chown "$run_as:$run_as" "$installer" 2>/dev/null || true
|
||||||
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
|
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
su - "$run_as" -s /bin/bash -c "bash -lc \"sh '$installer' $mode_flag\""
|
||||||
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
|
|
||||||
else
|
|
||||||
su - "$run_as" -c "sh '$installer' $mode_flag"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
||||||
sh "$installer" "$mode_flag"
|
sh "$installer" "$mode_flag"
|
||||||
|
|||||||
@@ -36,16 +36,17 @@ real_exe() {
|
|||||||
|
|
||||||
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
|
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
|
||||||
resolve_nix_bin() {
|
resolve_nix_bin() {
|
||||||
local nix_cmd=""
|
# IMPORTANT: prefer distro-managed locations first.
|
||||||
nix_cmd="$(command -v nix 2>/dev/null || true)"
|
# This avoids pinning /usr/local/bin/nix to a stale user-profile nix binary.
|
||||||
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
|
|
||||||
|
|
||||||
# IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
|
|
||||||
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
|
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
|
||||||
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
|
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
|
||||||
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
|
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
|
||||||
|
|
||||||
# /usr/local last, and only if it resolves to a real executable
|
local nix_cmd=""
|
||||||
|
nix_cmd="$(command -v nix 2>/dev/null || true)"
|
||||||
|
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
|
||||||
|
|
||||||
|
# /usr/local after system locations, and only if it resolves to a real executable
|
||||||
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
|
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
|
||||||
|
|
||||||
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
|
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ echo ">>> Running E2E tests: $PKGMGR_DISTRO"
|
|||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/opt/src/pkgmgr" \
|
||||||
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
||||||
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
||||||
-e REINSTALL_PKGMGR=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
--workdir /src \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
|
--workdir /opt/src/pkgmgr \
|
||||||
"pkgmgr-${PKGMGR_DISTRO}" \
|
"pkgmgr-${PKGMGR_DISTRO}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -40,14 +41,14 @@ docker run --rm \
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Mark the mounted repository as safe to avoid Git ownership errors.
|
# Mark the mounted repository as safe to avoid Git ownership errors.
|
||||||
# Newer Git (e.g. on Ubuntu) complains about the gitdir (/src/.git),
|
# Newer Git (e.g. on Ubuntu) complains about the gitdir (/opt/src/pkgmgr/.git),
|
||||||
# older versions about the worktree (/src). Nix turns "." into the
|
# older versions about the worktree (/opt/src/pkgmgr). Nix turns "." into the
|
||||||
# flake input "git+file:///src", which then uses Git under the hood.
|
# flake input "git+file:///opt/src/pkgmgr", which then uses Git under the hood.
|
||||||
if command -v git >/dev/null 2>&1; then
|
if command -v git >/dev/null 2>&1; then
|
||||||
# Worktree path
|
# Worktree path
|
||||||
git config --global --add safe.directory /src || true
|
git config --global --add safe.directory /opt/src/pkgmgr || true
|
||||||
# Gitdir path shown in the "dubious ownership" error
|
# Gitdir path shown in the "dubious ownership" error
|
||||||
git config --global --add safe.directory /src/.git || true
|
git config --global --add safe.directory /opt/src/pkgmgr/.git || true
|
||||||
# Ephemeral CI containers: allow all paths as a last resort
|
# Ephemeral CI containers: allow all paths as a last resort
|
||||||
git config --global --add safe.directory "*" || true
|
git config --global --add safe.directory "*" || true
|
||||||
fi
|
fi
|
||||||
@@ -55,6 +56,6 @@ docker run --rm \
|
|||||||
# Run the E2E tests inside the Nix development shell
|
# Run the E2E tests inside the Nix development shell
|
||||||
nix develop .#default --no-write-lock-file -c \
|
nix develop .#default --no-write-lock-file -c \
|
||||||
python3 -m unittest discover \
|
python3 -m unittest discover \
|
||||||
-s /src/tests/e2e \
|
-s /opt/src/pkgmgr/tests/e2e \
|
||||||
-p "$TEST_PATTERN"
|
-p "$TEST_PATTERN"
|
||||||
'
|
'
|
||||||
@@ -9,18 +9,19 @@ echo ">>> Image: ${IMAGE}"
|
|||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/opt/src/pkgmgr" \
|
||||||
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
||||||
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
||||||
--workdir /src \
|
--workdir /opt/src/pkgmgr \
|
||||||
-e REINSTALL_PKGMGR=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
"${IMAGE}" \
|
"${IMAGE}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if command -v git >/dev/null 2>&1; then
|
if command -v git >/dev/null 2>&1; then
|
||||||
git config --global --add safe.directory /src || true
|
git config --global --add safe.directory /opt/src/pkgmgr || true
|
||||||
git config --global --add safe.directory /src/.git || true
|
git config --global --add safe.directory /opt/src/pkgmgr/.git || true
|
||||||
git config --global --add safe.directory "*" || true
|
git config --global --add safe.directory "*" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -38,9 +39,9 @@ docker run --rm \
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Retry helper for GitHub API rate-limit (HTTP 403)
|
# Retry helper for GitHub API rate-limit (HTTP 403)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
if [[ -f /src/scripts/nix/lib/retry_403.sh ]]; then
|
if [[ -f /opt/src/pkgmgr/scripts/nix/lib/retry_403.sh ]]; then
|
||||||
# shellcheck source=./scripts/nix/lib/retry_403.sh
|
# shellcheck source=./scripts/nix/lib/retry_403.sh
|
||||||
source /src/scripts/nix/lib/retry_403.sh
|
source /opt/src/pkgmgr/scripts/nix/lib/retry_403.sh
|
||||||
elif [[ -f ./scripts/nix/lib/retry_403.sh ]]; then
|
elif [[ -f ./scripts/nix/lib/retry_403.sh ]]; then
|
||||||
# shellcheck source=./scripts/nix/lib/retry_403.sh
|
# shellcheck source=./scripts/nix/lib/retry_403.sh
|
||||||
source ./scripts/nix/lib/retry_403.sh
|
source ./scripts/nix/lib/retry_403.sh
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ echo
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
if OUTPUT=$(docker run --rm \
|
if OUTPUT=$(docker run --rm \
|
||||||
-e REINSTALL_PKGMGR=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/opt/src/pkgmgr" \
|
||||||
-w /src \
|
-w /opt/src/pkgmgr \
|
||||||
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
"${IMAGE}" \
|
"${IMAGE}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ echo ">>> Running INTEGRATION tests in ${PKGMGR_DISTRO} container"
|
|||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/opt/src/pkgmgr" \
|
||||||
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
||||||
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
||||||
--workdir /src \
|
--workdir /opt/src/pkgmgr \
|
||||||
-e REINSTALL_PKGMGR=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
"pkgmgr-${PKGMGR_DISTRO}" \
|
"pkgmgr-${PKGMGR_DISTRO}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -e;
|
set -e;
|
||||||
git config --global --add safe.directory /src || true;
|
git config --global --add safe.directory /opt/src/pkgmgr || true;
|
||||||
nix develop .#default --no-write-lock-file -c \
|
nix develop .#default --no-write-lock-file -c \
|
||||||
python3 -m unittest discover \
|
python3 -m unittest discover \
|
||||||
-s tests/integration \
|
-s tests/integration \
|
||||||
-t /src \
|
-t /opt/src/pkgmgr \
|
||||||
-p "$TEST_PATTERN";
|
-p "$TEST_PATTERN";
|
||||||
'
|
'
|
||||||
@@ -6,19 +6,20 @@ echo ">>> Running UNIT tests in ${PKGMGR_DISTRO} container"
|
|||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/opt/src/pkgmgr" \
|
||||||
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
|
||||||
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
-v "pkgmgr_nix_store_${PKGMGR_DISTRO}:/nix" \
|
||||||
--workdir /src \
|
--workdir /opt/src/pkgmgr \
|
||||||
-e REINSTALL_PKGMGR=1 \
|
-e REINSTALL_PKGMGR=1 \
|
||||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||||
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
"pkgmgr-${PKGMGR_DISTRO}" \
|
"pkgmgr-${PKGMGR_DISTRO}" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -e;
|
set -e;
|
||||||
git config --global --add safe.directory /src || true;
|
git config --global --add safe.directory /opt/src/pkgmgr || true;
|
||||||
nix develop .#default --no-write-lock-file -c \
|
nix develop .#default --no-write-lock-file -c \
|
||||||
python3 -m unittest discover \
|
python3 -m unittest discover \
|
||||||
-s tests/unit \
|
-s tests/unit \
|
||||||
-t /src \
|
-t /opt/src/pkgmgr \
|
||||||
-p "$TEST_PATTERN";
|
-p "$TEST_PATTERN";
|
||||||
'
|
'
|
||||||
@@ -25,12 +25,12 @@ __all__ = ["cli"]
|
|||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
def __getattr__(name: str) -> Any:
|
||||||
"""
|
"""
|
||||||
Lazily expose ``pkgmgr.cli`` as attribute on the top-level package.
|
Lazily expose ``pkgmgr.cli`` as attribute on the top-level package.
|
||||||
|
|
||||||
This keeps ``import pkgmgr`` lightweight while still allowing
|
This keeps ``import pkgmgr`` lightweight while still allowing
|
||||||
``from pkgmgr import cli`` in tests and entry points.
|
``from pkgmgr import cli`` in tests and entry points.
|
||||||
"""
|
"""
|
||||||
if name == "cli":
|
if name == "cli":
|
||||||
return import_module("pkgmgr.cli")
|
return import_module("pkgmgr.cli")
|
||||||
raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}")
|
raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}")
|
||||||
@@ -3,4 +3,4 @@ from __future__ import annotations
|
|||||||
# expose subpackages for patch() / resolve_name() friendliness
|
# expose subpackages for patch() / resolve_name() friendliness
|
||||||
from . import release as release # noqa: F401
|
from . import release as release # noqa: F401
|
||||||
|
|
||||||
__all__ = ["release"]
|
__all__ = ["release"]
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitError
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
from pkgmgr.core.git.queries import get_current_branch
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
@@ -32,7 +32,7 @@ def close_branch(
|
|||||||
if not name:
|
if not name:
|
||||||
try:
|
try:
|
||||||
name = get_current_branch(cwd=cwd)
|
name = get_current_branch(cwd=cwd)
|
||||||
except GitError as exc:
|
except GitRunError as exc:
|
||||||
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
|
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
@@ -48,14 +48,18 @@ def close_branch(
|
|||||||
|
|
||||||
# Confirmation
|
# Confirmation
|
||||||
if not force:
|
if not force:
|
||||||
answer = input(
|
answer = (
|
||||||
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): "
|
input(
|
||||||
).strip().lower()
|
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): "
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if answer != "y":
|
if answer != "y":
|
||||||
print("Aborted closing branch.")
|
print("Aborted closing branch.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Execute workflow (commands raise specific GitError subclasses)
|
# Execute workflow (commands raise specific GitRunError subclasses)
|
||||||
fetch("origin", cwd=cwd)
|
fetch("origin", cwd=cwd)
|
||||||
checkout(target_base, cwd=cwd)
|
checkout(target_base, cwd=cwd)
|
||||||
pull("origin", target_base, cwd=cwd)
|
pull("origin", target_base, cwd=cwd)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitError
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
from pkgmgr.core.git.queries import get_current_branch
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
@@ -26,7 +26,7 @@ def drop_branch(
|
|||||||
if not name:
|
if not name:
|
||||||
try:
|
try:
|
||||||
name = get_current_branch(cwd=cwd)
|
name = get_current_branch(cwd=cwd)
|
||||||
except GitError as exc:
|
except GitRunError as exc:
|
||||||
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
|
raise RuntimeError(f"Failed to detect current branch: {exc}") from exc
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
@@ -41,15 +41,19 @@ def drop_branch(
|
|||||||
|
|
||||||
# Confirmation
|
# Confirmation
|
||||||
if not force:
|
if not force:
|
||||||
answer = input(
|
answer = (
|
||||||
f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): "
|
input(
|
||||||
).strip().lower()
|
f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): "
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if answer != "y":
|
if answer != "y":
|
||||||
print("Aborted dropping branch.")
|
print("Aborted dropping branch.")
|
||||||
return
|
return
|
||||||
|
|
||||||
delete_local_branch(name, cwd=cwd, force=False)
|
delete_local_branch(name, cwd=cwd, force=False)
|
||||||
|
|
||||||
# Remote delete (special-case message)
|
# Remote delete (special-case message)
|
||||||
try:
|
try:
|
||||||
delete_remote_branch("origin", name, cwd=cwd)
|
delete_remote_branch("origin", name, cwd=cwd)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def open_branch(
|
|||||||
|
|
||||||
resolved_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
resolved_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||||
|
|
||||||
# Workflow (commands raise specific GitError subclasses)
|
# Workflow (commands raise specific GitBaseError subclasses)
|
||||||
fetch("origin", cwd=cwd)
|
fetch("origin", cwd=cwd)
|
||||||
checkout(resolved_base, cwd=cwd)
|
checkout(resolved_base, cwd=cwd)
|
||||||
pull("origin", resolved_base, cwd=cwd)
|
pull("origin", resolved_base, cwd=cwd)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import yaml
|
import yaml
|
||||||
import os
|
import os
|
||||||
from pkgmgr.core.config.save import save_user_config
|
from pkgmgr.core.config.save import save_user_config
|
||||||
|
|
||||||
def interactive_add(config,USER_CONFIG_PATH:str):
|
|
||||||
|
def interactive_add(config, USER_CONFIG_PATH: str):
|
||||||
"""Interactively prompt the user to add a new repository entry to the user config."""
|
"""Interactively prompt the user to add a new repository entry to the user config."""
|
||||||
print("Adding a new repository configuration entry.")
|
print("Adding a new repository configuration entry.")
|
||||||
new_entry = {}
|
new_entry = {}
|
||||||
new_entry["provider"] = input("Provider (e.g., github.com): ").strip()
|
new_entry["provider"] = input("Provider (e.g., github.com): ").strip()
|
||||||
new_entry["account"] = input("Account (e.g., yourusername): ").strip()
|
new_entry["account"] = input("Account (e.g., yourusername): ").strip()
|
||||||
new_entry["repository"] = input("Repository name (e.g., mytool): ").strip()
|
new_entry["repository"] = input("Repository name (e.g., mytool): ").strip()
|
||||||
new_entry["command"] = input("Command (optional, leave blank to auto-detect): ").strip()
|
new_entry["command"] = input(
|
||||||
|
"Command (optional, leave blank to auto-detect): "
|
||||||
|
).strip()
|
||||||
new_entry["description"] = input("Description (optional): ").strip()
|
new_entry["description"] = input("Description (optional): ").strip()
|
||||||
new_entry["replacement"] = input("Replacement (optional): ").strip()
|
new_entry["replacement"] = input("Replacement (optional): ").strip()
|
||||||
new_entry["alias"] = input("Alias (optional): ").strip()
|
new_entry["alias"] = input("Alias (optional): ").strip()
|
||||||
@@ -25,12 +28,12 @@ def interactive_add(config,USER_CONFIG_PATH:str):
|
|||||||
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
||||||
if confirm == "y":
|
if confirm == "y":
|
||||||
if os.path.exists(USER_CONFIG_PATH):
|
if os.path.exists(USER_CONFIG_PATH):
|
||||||
with open(USER_CONFIG_PATH, 'r') as f:
|
with open(USER_CONFIG_PATH, "r") as f:
|
||||||
user_config = yaml.safe_load(f) or {}
|
user_config = yaml.safe_load(f) or {}
|
||||||
else:
|
else:
|
||||||
user_config = {"repositories": []}
|
user_config = {"repositories": []}
|
||||||
user_config.setdefault("repositories", [])
|
user_config.setdefault("repositories", [])
|
||||||
user_config["repositories"].append(new_entry)
|
user_config["repositories"].append(new_entry)
|
||||||
save_user_config(user_config,USER_CONFIG_PATH)
|
save_user_config(user_config, USER_CONFIG_PATH)
|
||||||
else:
|
else:
|
||||||
print("Entry not added.")
|
print("Entry not added.")
|
||||||
@@ -107,11 +107,15 @@ def config_init(
|
|||||||
# Already known?
|
# Already known?
|
||||||
if key in default_keys:
|
if key in default_keys:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
print(f"[SKIP] (defaults) {provider}/{account}/{repo_name}")
|
print(
|
||||||
|
f"[SKIP] (defaults) {provider}/{account}/{repo_name}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
if key in existing_keys:
|
if key in existing_keys:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
print(f"[SKIP] (user-config) {provider}/{account}/{repo_name}")
|
print(
|
||||||
|
f"[SKIP] (user-config) {provider}/{account}/{repo_name}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"[ADD] {provider}/{account}/{repo_name}")
|
print(f"[ADD] {provider}/{account}/{repo_name}")
|
||||||
@@ -121,7 +125,9 @@ def config_init(
|
|||||||
if verified_commit:
|
if verified_commit:
|
||||||
print(f"[INFO] Latest commit: {verified_commit}")
|
print(f"[INFO] Latest commit: {verified_commit}")
|
||||||
else:
|
else:
|
||||||
print("[WARN] Could not read commit (not a git repo or no commits).")
|
print(
|
||||||
|
"[WARN] Could not read commit (not a git repo or no commits)."
|
||||||
|
)
|
||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: Dict[str, Any] = {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import yaml
|
import yaml
|
||||||
from pkgmgr.core.config.load import load_config
|
from pkgmgr.core.config.load import load_config
|
||||||
|
|
||||||
|
|
||||||
def show_config(selected_repos, user_config_path, full_config=False):
|
def show_config(selected_repos, user_config_path, full_config=False):
|
||||||
"""Display configuration for one or more repositories, or the entire merged config."""
|
"""Display configuration for one or more repositories, or the entire merged config."""
|
||||||
if full_config:
|
if full_config:
|
||||||
@@ -8,8 +9,10 @@ def show_config(selected_repos, user_config_path, full_config=False):
|
|||||||
print(yaml.dump(merged, default_flow_style=False))
|
print(yaml.dump(merged, default_flow_style=False))
|
||||||
else:
|
else:
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
identifier = f'{repo.get("provider")}/{repo.get("account")}/{repo.get("repository")}'
|
identifier = (
|
||||||
|
f"{repo.get('provider')}/{repo.get('account')}/{repo.get('repository')}"
|
||||||
|
)
|
||||||
print(f"Repository: {identifier}")
|
print(f"Repository: {identifier}")
|
||||||
for key, value in repo.items():
|
for key, value in repo.items():
|
||||||
print(f" {key}: {value}")
|
print(f" {key}: {value}")
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
@@ -66,10 +66,7 @@ def _ensure_repo_dir(
|
|||||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||||
|
|
||||||
if not os.path.exists(repo_dir):
|
if not os.path.exists(repo_dir):
|
||||||
print(
|
print(f"Repository directory '{repo_dir}' does not exist. Cloning it now...")
|
||||||
f"Repository directory '{repo_dir}' does not exist. "
|
|
||||||
"Cloning it now..."
|
|
||||||
)
|
|
||||||
clone_repos(
|
clone_repos(
|
||||||
[repo],
|
[repo],
|
||||||
repositories_base_dir,
|
repositories_base_dir,
|
||||||
@@ -79,10 +76,7 @@ def _ensure_repo_dir(
|
|||||||
clone_mode,
|
clone_mode,
|
||||||
)
|
)
|
||||||
if not os.path.exists(repo_dir):
|
if not os.path.exists(repo_dir):
|
||||||
print(
|
print(f"Cloning failed for repository {identifier}. Skipping installation.")
|
||||||
f"Cloning failed for repository {identifier}. "
|
|
||||||
"Skipping installation."
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return repo_dir
|
return repo_dir
|
||||||
@@ -115,7 +109,9 @@ def _verify_repo(
|
|||||||
|
|
||||||
if silent:
|
if silent:
|
||||||
# Non-interactive mode: continue with a warning.
|
# Non-interactive mode: continue with a warning.
|
||||||
print(f"[Warning] Continuing despite verification failure for {identifier} (--silent).")
|
print(
|
||||||
|
f"[Warning] Continuing despite verification failure for {identifier} (--silent)."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
choice = input("Continue anyway? [y/N]: ").strip().lower()
|
choice = input("Continue anyway? [y/N]: ").strip().lower()
|
||||||
if choice != "y":
|
if choice != "y":
|
||||||
@@ -232,12 +228,16 @@ def install_repos(
|
|||||||
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||||
failures.append((identifier, f"installer failed (exit={code})"))
|
failures.append((identifier, f"installer failed (exit={code})"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] install: repository {identifier} failed (exit={code}). Continuing...")
|
print(
|
||||||
|
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures.append((identifier, f"unexpected error: {exc}"))
|
failures.append((identifier, f"unexpected error: {exc}"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing...")
|
print(
|
||||||
|
f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if failures and emit_summary and not quiet:
|
if failures and emit_summary and not quiet:
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F4
|
|||||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||||
|
|
||||||
# OS-specific installers
|
# OS-specific installers
|
||||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||||
from pkgmgr.actions.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
||||||
|
) # noqa: F401
|
||||||
|
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
||||||
|
DebianControlInstaller as DebianControlInstaller,
|
||||||
|
) # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||||
@@ -41,7 +41,9 @@ class BaseInstaller(ABC):
|
|||||||
return caps
|
return caps
|
||||||
|
|
||||||
for matcher in CAPABILITY_MATCHERS:
|
for matcher in CAPABILITY_MATCHERS:
|
||||||
if matcher.applies_to_layer(self.layer) and matcher.is_provided(ctx, self.layer):
|
if matcher.applies_to_layer(self.layer) and matcher.is_provided(
|
||||||
|
ctx, self.layer
|
||||||
|
):
|
||||||
caps.add(matcher.name)
|
caps.add(matcher.name)
|
||||||
|
|
||||||
return caps
|
return caps
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
def supports(self, ctx: RepoContext) -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print("[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 – skipping MakefileInstaller.")
|
print(
|
||||||
|
"[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 – skipping MakefileInstaller."
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
@@ -46,7 +48,9 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)")
|
print(
|
||||||
|
f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)"
|
||||||
|
)
|
||||||
|
|
||||||
run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview)
|
run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ class NixConflictResolver:
|
|||||||
|
|
||||||
# 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`)
|
# 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
tokens = self._profile.find_remove_tokens_for_output(
|
||||||
|
ctx, self._runner, output
|
||||||
|
)
|
||||||
|
|
||||||
if tokens:
|
if tokens:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
@@ -94,7 +96,9 @@ class NixConflictResolver:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print("[nix] conflict detected but could not resolve profile entries to remove.")
|
print(
|
||||||
|
"[nix] conflict detected but could not resolve profile entries to remove."
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -75,7 +75,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# Core install path
|
# Core install path
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _install_only(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
def _install_only(
|
||||||
|
self, ctx: "RepoContext", output: str, allow_failure: bool
|
||||||
|
) -> None:
|
||||||
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
||||||
|
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
@@ -96,7 +98,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
output=output,
|
output=output,
|
||||||
):
|
):
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(f"[nix] output '{output}' successfully installed after conflict cleanup.")
|
print(
|
||||||
|
f"[nix] output '{output}' successfully installed after conflict cleanup."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
@@ -107,20 +111,26 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
|
|
||||||
# If indices are supported, try legacy index-upgrade path.
|
# If indices are supported, try legacy index-upgrade path.
|
||||||
if self._indices_supported is not False:
|
if self._indices_supported is not False:
|
||||||
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output)
|
indices = self._profile.find_installed_indices_for_output(
|
||||||
|
ctx, self._runner, output
|
||||||
|
)
|
||||||
|
|
||||||
upgraded = False
|
upgraded = False
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
if self._upgrade_index(ctx, idx):
|
if self._upgrade_index(ctx, idx):
|
||||||
upgraded = True
|
upgraded = True
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
|
print(
|
||||||
|
f"[nix] output '{output}' successfully upgraded (index {idx})."
|
||||||
|
)
|
||||||
|
|
||||||
if upgraded:
|
if upgraded:
|
||||||
return
|
return
|
||||||
|
|
||||||
if indices and not ctx.quiet:
|
if indices and not ctx.quiet:
|
||||||
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
|
print(
|
||||||
|
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
|
||||||
|
)
|
||||||
|
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
self._remove_index(ctx, idx)
|
self._remove_index(ctx, idx)
|
||||||
@@ -139,7 +149,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
print(f"[nix] output '{output}' successfully re-installed.")
|
print(f"[nix] output '{output}' successfully re-installed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})")
|
print(
|
||||||
|
f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})"
|
||||||
|
)
|
||||||
if not allow_failure:
|
if not allow_failure:
|
||||||
raise SystemExit(final.returncode)
|
raise SystemExit(final.returncode)
|
||||||
|
|
||||||
@@ -149,7 +161,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# force_update path
|
# force_update path
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _force_upgrade_output(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
def _force_upgrade_output(
|
||||||
|
self, ctx: "RepoContext", output: str, allow_failure: bool
|
||||||
|
) -> None:
|
||||||
# Prefer token path if indices unsupported (new nix)
|
# Prefer token path if indices unsupported (new nix)
|
||||||
if self._indices_supported is False:
|
if self._indices_supported is False:
|
||||||
self._remove_tokens_for_output(ctx, output)
|
self._remove_tokens_for_output(ctx, output)
|
||||||
@@ -158,14 +172,18 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
print(f"[nix] output '{output}' successfully upgraded.")
|
print(f"[nix] output '{output}' successfully upgraded.")
|
||||||
return
|
return
|
||||||
|
|
||||||
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output)
|
indices = self._profile.find_installed_indices_for_output(
|
||||||
|
ctx, self._runner, output
|
||||||
|
)
|
||||||
|
|
||||||
upgraded_any = False
|
upgraded_any = False
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
if self._upgrade_index(ctx, idx):
|
if self._upgrade_index(ctx, idx):
|
||||||
upgraded_any = True
|
upgraded_any = True
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
|
print(
|
||||||
|
f"[nix] output '{output}' successfully upgraded (index {idx})."
|
||||||
|
)
|
||||||
|
|
||||||
if upgraded_any:
|
if upgraded_any:
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
@@ -173,7 +191,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if indices and not ctx.quiet:
|
if indices and not ctx.quiet:
|
||||||
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
|
print(
|
||||||
|
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
|
||||||
|
)
|
||||||
|
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
self._remove_index(ctx, idx)
|
self._remove_index(ctx, idx)
|
||||||
@@ -223,7 +243,9 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}")
|
print(
|
||||||
|
f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}"
|
||||||
|
)
|
||||||
|
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True)
|
self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True)
|
||||||
@@ -101,7 +101,9 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
tokens: List[str] = [out] # critical: matches nix's own suggestion for conflicts
|
tokens: List[str] = [
|
||||||
|
out
|
||||||
|
] # critical: matches nix's own suggestion for conflicts
|
||||||
|
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if entry_matches_output(e, out):
|
if entry_matches_output(e, out):
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ class NixProfileListReader:
|
|||||||
|
|
||||||
return uniq
|
return uniq
|
||||||
|
|
||||||
def indices_matching_store_prefixes(self, ctx: "RepoContext", prefixes: List[str]) -> List[int]:
|
def indices_matching_store_prefixes(
|
||||||
|
self, ctx: "RepoContext", prefixes: List[str]
|
||||||
|
) -> List[int]:
|
||||||
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
||||||
prefixes = [p for p in prefixes if p]
|
prefixes = [p for p in prefixes if p]
|
||||||
if not prefixes:
|
if not prefixes:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
|||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from .runner import CommandRunner
|
from .runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RetryPolicy:
|
class RetryPolicy:
|
||||||
max_attempts: int = 7
|
max_attempts: int = 7
|
||||||
@@ -35,13 +36,19 @@ class GitHubRateLimitRetry:
|
|||||||
install_cmd: str,
|
install_cmd: str,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
quiet = bool(getattr(ctx, "quiet", False))
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
delays = list(self._fibonacci_backoff(self._policy.base_delay_seconds, self._policy.max_attempts))
|
delays = list(
|
||||||
|
self._fibonacci_backoff(
|
||||||
|
self._policy.base_delay_seconds, self._policy.max_attempts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
last: RunResult | None = None
|
last: RunResult | None = None
|
||||||
|
|
||||||
for attempt, base_delay in enumerate(delays, start=1):
|
for attempt, base_delay in enumerate(delays, start=1):
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[nix] attempt {attempt}/{self._policy.max_attempts}: {install_cmd}")
|
print(
|
||||||
|
f"[nix] attempt {attempt}/{self._policy.max_attempts}: {install_cmd}"
|
||||||
|
)
|
||||||
|
|
||||||
res = runner.run(ctx, install_cmd, allow_failure=True)
|
res = runner.run(ctx, install_cmd, allow_failure=True)
|
||||||
last = res
|
last = res
|
||||||
@@ -56,7 +63,9 @@ class GitHubRateLimitRetry:
|
|||||||
if attempt >= self._policy.max_attempts:
|
if attempt >= self._policy.max_attempts:
|
||||||
break
|
break
|
||||||
|
|
||||||
jitter = random.randint(self._policy.jitter_seconds_min, self._policy.jitter_seconds_max)
|
jitter = random.randint(
|
||||||
|
self._policy.jitter_seconds_min, self._policy.jitter_seconds_max
|
||||||
|
)
|
||||||
wait_time = base_delay + jitter
|
wait_time = base_delay + jitter
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
@@ -67,7 +76,11 @@ class GitHubRateLimitRetry:
|
|||||||
|
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
|
|
||||||
return last if last is not None else RunResult(returncode=1, stdout="", stderr="nix install retry failed")
|
return (
|
||||||
|
last
|
||||||
|
if last is not None
|
||||||
|
else RunResult(returncode=1, stdout="", stderr="nix install retry failed")
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_github_rate_limit_error(text: str) -> bool:
|
def _is_github_rate_limit_error(text: str) -> bool:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .types import RunResult
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
|
||||||
|
|
||||||
class CommandRunner:
|
class CommandRunner:
|
||||||
"""
|
"""
|
||||||
Executes commands (shell=True) inside a repository directory (if provided).
|
Executes commands (shell=True) inside a repository directory (if provided).
|
||||||
@@ -40,7 +41,9 @@ class CommandRunner:
|
|||||||
raise
|
raise
|
||||||
return RunResult(returncode=1, stdout="", stderr=str(e))
|
return RunResult(returncode=1, stdout="", stderr=str(e))
|
||||||
|
|
||||||
res = RunResult(returncode=p.returncode, stdout=p.stdout or "", stderr=p.stderr or "")
|
res = RunResult(
|
||||||
|
returncode=p.returncode, stdout=p.stdout or "", stderr=p.stderr or ""
|
||||||
|
)
|
||||||
|
|
||||||
if res.returncode != 0 and not quiet:
|
if res.returncode != 0 and not quiet:
|
||||||
self._print_compact_failure(res)
|
self._print_compact_failure(res)
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ class NixConflictTextParser:
|
|||||||
tokens: List[str] = []
|
tokens: List[str] = []
|
||||||
for m in pat.finditer(text or ""):
|
for m in pat.finditer(text or ""):
|
||||||
t = (m.group(1) or "").strip()
|
t = (m.group(1) or "").strip()
|
||||||
if (t.startswith("'") and t.endswith("'")) or (t.startswith('"') and t.endswith('"')):
|
if (t.startswith("'") and t.endswith("'")) or (
|
||||||
|
t.startswith('"') and t.endswith('"')
|
||||||
|
):
|
||||||
t = t[1:-1]
|
t = t[1:-1]
|
||||||
if t:
|
if t:
|
||||||
tokens.append(t)
|
tokens.append(t)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ class PythonInstaller(BaseInstaller):
|
|||||||
|
|
||||||
def supports(self, ctx: RepoContext) -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
|
||||||
print("[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER.")
|
print(
|
||||||
|
"[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER."
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))
|
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ class InstallationPipeline:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
if ctx.force_update and state.layer is not None and installer_layer == state.layer:
|
if (
|
||||||
|
ctx.force_update
|
||||||
|
and state.layer is not None
|
||||||
|
and installer_layer == state.layer
|
||||||
|
):
|
||||||
print(
|
print(
|
||||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||||
f"for {identifier} in '{repo_dir}' (upgrade requested)..."
|
f"for {identifier} in '{repo_dir}' (upgrade requested)..."
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .list_cmd import list_mirrors
|
|||||||
from .diff_cmd import diff_mirrors
|
from .diff_cmd import diff_mirrors
|
||||||
from .merge_cmd import merge_mirrors
|
from .merge_cmd import merge_mirrors
|
||||||
from .setup_cmd import setup_mirrors
|
from .setup_cmd import setup_mirrors
|
||||||
|
from .visibility_cmd import set_mirror_visibility
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Repository",
|
"Repository",
|
||||||
@@ -22,4 +23,5 @@ __all__ = [
|
|||||||
"diff_mirrors",
|
"diff_mirrors",
|
||||||
"merge_mirrors",
|
"merge_mirrors",
|
||||||
"setup_mirrors",
|
"setup_mirrors",
|
||||||
|
"set_mirror_visibility",
|
||||||
]
|
]
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitError
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitAddRemoteError,
|
GitAddRemoteError,
|
||||||
GitAddRemotePushUrlError,
|
GitAddRemotePushUrlError,
|
||||||
@@ -90,7 +90,7 @@ def determine_primary_remote_url(
|
|||||||
def has_origin_remote(repo_dir: str) -> bool:
|
def has_origin_remote(repo_dir: str) -> bool:
|
||||||
try:
|
try:
|
||||||
return "origin" in list_remotes(cwd=repo_dir)
|
return "origin" in list_remotes(cwd=repo_dir)
|
||||||
except GitError:
|
except GitRunError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ def _ensure_additional_push_urls(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
existing = get_remote_push_urls("origin", cwd=repo_dir)
|
existing = get_remote_push_urls("origin", cwd=repo_dir)
|
||||||
except GitError:
|
except GitRunError:
|
||||||
existing = set()
|
existing = set()
|
||||||
|
|
||||||
for url in sorted(desired - existing):
|
for url in sorted(desired - existing):
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
from .types import MirrorMap, Repository
|
from .types import MirrorMap, Repository
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
|||||||
"""
|
"""
|
||||||
Supports:
|
Supports:
|
||||||
NAME URL
|
NAME URL
|
||||||
URL → auto name = hostname
|
URL -> auto-generate name from hostname
|
||||||
"""
|
"""
|
||||||
path = os.path.join(repo_dir, filename)
|
path = os.path.join(repo_dir, filename)
|
||||||
mirrors: MirrorMap = {}
|
mirrors: MirrorMap = {}
|
||||||
@@ -52,7 +53,8 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
|||||||
# Case 1: "name url"
|
# Case 1: "name url"
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
name, url = parts
|
name, url = parts
|
||||||
# Case 2: "url" → auto-generate name
|
|
||||||
|
# Case 2: "url" -> auto name
|
||||||
elif len(parts) == 1:
|
elif len(parts) == 1:
|
||||||
url = parts[0]
|
url = parts[0]
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -67,21 +69,56 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
mirrors[name] = url
|
mirrors[name] = url
|
||||||
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[WARN] Could not read MIRRORS file at {path}: {exc}")
|
print(f"[WARN] Could not read MIRRORS file at {path}: {exc}")
|
||||||
|
|
||||||
return mirrors
|
return mirrors
|
||||||
|
|
||||||
|
|
||||||
|
MirrorsInput = Union[Mapping[str, str], Iterable[str]]
|
||||||
|
|
||||||
|
|
||||||
def write_mirrors_file(
|
def write_mirrors_file(
|
||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
mirrors: Mapping[str, str],
|
mirrors: MirrorsInput,
|
||||||
filename: str = "MIRRORS",
|
filename: str = "MIRRORS",
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""
|
||||||
|
Write MIRRORS in one of two formats:
|
||||||
|
|
||||||
|
1) Mapping[str, str] -> "NAME URL" per line (legacy / compatible)
|
||||||
|
2) Iterable[str] -> "URL" per line (new preferred)
|
||||||
|
|
||||||
|
Strings are treated as a single URL (not iterated character-by-character).
|
||||||
|
"""
|
||||||
path = os.path.join(repo_dir, filename)
|
path = os.path.join(repo_dir, filename)
|
||||||
lines = [f"{name} {url}" for name, url in sorted(mirrors.items())]
|
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
if isinstance(mirrors, Mapping):
|
||||||
|
items = [
|
||||||
|
(str(name), str(url))
|
||||||
|
for name, url in mirrors.items()
|
||||||
|
if url is not None and str(url).strip()
|
||||||
|
]
|
||||||
|
items.sort(key=lambda x: (x[0], x[1]))
|
||||||
|
lines = [f"{name} {url}" for name, url in items]
|
||||||
|
|
||||||
|
else:
|
||||||
|
if isinstance(mirrors, (str, bytes)):
|
||||||
|
urls = [str(mirrors).strip()]
|
||||||
|
else:
|
||||||
|
urls = [
|
||||||
|
str(url).strip()
|
||||||
|
for url in mirrors
|
||||||
|
if url is not None and str(url).strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
urls = sorted(set(urls))
|
||||||
|
lines = urls
|
||||||
|
|
||||||
content = "\n".join(lines) + ("\n" if lines else "")
|
content = "\n".join(lines) + ("\n" if lines else "")
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
@@ -94,5 +131,6 @@ def write_mirrors_file(
|
|||||||
with open(path, "w", encoding="utf-8") as fh:
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
fh.write(content)
|
fh.write(content)
|
||||||
print(f"[INFO] Wrote MIRRORS file at {path}")
|
print(f"[INFO] Wrote MIRRORS file at {path}")
|
||||||
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[ERROR] Failed to write MIRRORS file at {path}: {exc}")
|
print(f"[ERROR] Failed to write MIRRORS file at {path}: {exc}")
|
||||||
@@ -16,6 +16,7 @@ from .types import MirrorMap, Repository
|
|||||||
# Helpers
|
# Helpers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
||||||
"""
|
"""
|
||||||
Normalised key for identifying a repository in config files.
|
Normalised key for identifying a repository in config files.
|
||||||
@@ -47,6 +48,7 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
|||||||
# Main merge command
|
# Main merge command
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def merge_mirrors(
|
def merge_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: List[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
|
|||||||
@@ -11,35 +11,37 @@ from .types import Repository
|
|||||||
from .url_utils import normalize_provider_host, parse_repo_from_git_url
|
from .url_utils import normalize_provider_host, parse_repo_from_git_url
|
||||||
|
|
||||||
|
|
||||||
def ensure_remote_repository(
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
repo: Repository,
|
h = (host or "").lower()
|
||||||
repositories_base_dir: str,
|
if h == "github.com":
|
||||||
all_repos: List[Repository],
|
return "github"
|
||||||
|
# Best-effort default for self-hosted git domains
|
||||||
|
return "gitea" if h else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_remote_repository_for_url(
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
private_default: bool,
|
||||||
|
description: str,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
host_raw, owner, name = parse_repo_from_git_url(url)
|
||||||
|
|
||||||
primary_url = determine_primary_remote_url(repo, ctx)
|
|
||||||
if not primary_url:
|
|
||||||
print("[INFO] No primary URL found; skipping remote provisioning.")
|
|
||||||
return
|
|
||||||
|
|
||||||
host_raw, owner, name = parse_repo_from_git_url(primary_url)
|
|
||||||
host = normalize_provider_host(host_raw)
|
host = normalize_provider_host(host_raw)
|
||||||
|
|
||||||
if not host or not owner or not name:
|
if not host or not owner or not name:
|
||||||
print("[WARN] Could not parse remote URL:", primary_url)
|
print(f"[WARN] Could not parse repo from URL: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
spec = RepoSpec(
|
spec = RepoSpec(
|
||||||
host=host,
|
host=host,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
name=name,
|
name=name,
|
||||||
private=bool(repo.get("private", True)),
|
private=private_default,
|
||||||
description=str(repo.get("description", "")),
|
description=description,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_kind = str(repo.get("provider", "")).lower() or None
|
provider_kind = _provider_hint_from_host(host)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = ensure_remote_repo(
|
result = ensure_remote_repo(
|
||||||
@@ -56,4 +58,29 @@ def ensure_remote_repository(
|
|||||||
if result.url:
|
if result.url:
|
||||||
print(f"[REMOTE ENSURE] URL: {result.url}")
|
print(f"[REMOTE ENSURE] URL: {result.url}")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
print(f"[ERROR] Remote provisioning failed: {exc}")
|
print(f"[ERROR] Remote provisioning failed for {url!r}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_remote_repository(
|
||||||
|
repo: Repository,
|
||||||
|
repositories_base_dir: str,
|
||||||
|
all_repos: List[Repository],
|
||||||
|
preview: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Backwards-compatible wrapper: ensure the *primary* remote repository
|
||||||
|
derived from the primary URL.
|
||||||
|
"""
|
||||||
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
|
|
||||||
|
primary_url = determine_primary_remote_url(repo, ctx)
|
||||||
|
if not primary_url:
|
||||||
|
print("[INFO] No primary URL found; skipping remote provisioning.")
|
||||||
|
return
|
||||||
|
|
||||||
|
ensure_remote_repository_for_url(
|
||||||
|
url=primary_url,
|
||||||
|
private_default=bool(repo.get("private", True)),
|
||||||
|
description=str(repo.get("description", "")),
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import probe_remote_reachable
|
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
||||||
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .git_remote import ensure_origin_remote, determine_primary_remote_url
|
from .git_remote import determine_primary_remote_url, ensure_origin_remote
|
||||||
from .remote_provision import ensure_remote_repository
|
from .remote_provision import ensure_remote_repository_for_url
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
from .url_utils import normalize_provider_host, parse_repo_from_git_url
|
||||||
|
|
||||||
|
|
||||||
def _is_git_remote_url(url: str) -> bool:
|
def _is_git_remote_url(url: str) -> bool:
|
||||||
@@ -25,6 +28,64 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
|
h = (host or "").lower()
|
||||||
|
if h == "github.com":
|
||||||
|
return "github"
|
||||||
|
return "gitea" if h else None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_visibility_for_url(
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
private: bool,
|
||||||
|
description: str,
|
||||||
|
preview: bool,
|
||||||
|
) -> None:
|
||||||
|
host_raw, owner, name = parse_repo_from_git_url(url)
|
||||||
|
host = normalize_provider_host(host_raw)
|
||||||
|
|
||||||
|
if not host or not owner or not name:
|
||||||
|
print(f"[WARN] Could not parse repo from URL: {url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = RepoSpec(
|
||||||
|
host=host,
|
||||||
|
owner=owner,
|
||||||
|
name=name,
|
||||||
|
private=private,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_kind = _provider_hint_from_host(host)
|
||||||
|
res = set_repo_visibility(
|
||||||
|
spec,
|
||||||
|
private=private,
|
||||||
|
provider_hint=ProviderHint(kind=provider_kind),
|
||||||
|
options=VisibilityOptions(preview=preview),
|
||||||
|
)
|
||||||
|
print(f"[REMOTE VISIBILITY] {res.status.upper()}: {res.message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
|
||||||
|
"""
|
||||||
|
Print probe result for a git remote URL, including a short failure reason.
|
||||||
|
"""
|
||||||
|
ok, reason = probe_remote_reachable_detail(url, cwd=cwd)
|
||||||
|
|
||||||
|
prefix = f"{name}: " if name else ""
|
||||||
|
if ok:
|
||||||
|
print(f"[OK] {prefix}{url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[WARN] {prefix}{url}")
|
||||||
|
if reason:
|
||||||
|
reason = reason.strip()
|
||||||
|
if len(reason) > 240:
|
||||||
|
reason = reason[:240].rstrip() + "…"
|
||||||
|
print(f" reason: {reason}")
|
||||||
|
|
||||||
|
|
||||||
def _setup_local_mirrors_for_repo(
|
def _setup_local_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
@@ -48,6 +109,7 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
all_repos: List[Repository],
|
all_repos: List[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
ensure_remote: bool,
|
ensure_remote: bool,
|
||||||
|
ensure_visibility: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
|
|
||||||
@@ -56,33 +118,78 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}")
|
print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}")
|
||||||
print("------------------------------------------------------------")
|
print("------------------------------------------------------------")
|
||||||
|
|
||||||
if ensure_remote:
|
git_mirrors = {
|
||||||
ensure_remote_repository(
|
k: v for k, v in ctx.resolved_mirrors.items() if _is_git_remote_url(v)
|
||||||
repo,
|
}
|
||||||
repositories_base_dir,
|
|
||||||
all_repos,
|
|
||||||
preview,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Probe only git URLs (do not try ls-remote against PyPI etc.)
|
def _desired_private_default() -> bool:
|
||||||
# If there are no mirrors at all, probe the primary git URL.
|
# default behavior: repo['private'] (or True)
|
||||||
git_mirrors = {k: v for k, v in ctx.resolved_mirrors.items() if _is_git_remote_url(v)}
|
if ensure_visibility == "public":
|
||||||
|
return False
|
||||||
|
if ensure_visibility == "private":
|
||||||
|
return True
|
||||||
|
return bool(repo.get("private", True))
|
||||||
|
|
||||||
|
def _should_enforce_visibility() -> bool:
|
||||||
|
return ensure_visibility in ("public", "private")
|
||||||
|
|
||||||
|
def _visibility_private_value() -> bool:
|
||||||
|
return ensure_visibility == "private"
|
||||||
|
|
||||||
|
description = str(repo.get("description", ""))
|
||||||
|
|
||||||
|
# If there are no git mirrors, fall back to primary (git) URL.
|
||||||
if not git_mirrors:
|
if not git_mirrors:
|
||||||
primary = determine_primary_remote_url(repo, ctx)
|
primary = determine_primary_remote_url(repo, ctx)
|
||||||
if not primary or not _is_git_remote_url(primary):
|
if not primary or not _is_git_remote_url(primary):
|
||||||
print("[INFO] No git mirrors to probe.")
|
print("[INFO] No git mirrors to probe or provision.")
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
ok = probe_remote_reachable(primary, cwd=ctx.repo_dir)
|
if ensure_remote:
|
||||||
print("[OK]" if ok else "[WARN]", primary)
|
print(f"[REMOTE ENSURE] ensuring primary: {primary}")
|
||||||
|
ensure_remote_repository_for_url(
|
||||||
|
url=primary,
|
||||||
|
private_default=_desired_private_default(),
|
||||||
|
description=description,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
# IMPORTANT: enforce visibility only if requested
|
||||||
|
if _should_enforce_visibility():
|
||||||
|
_apply_visibility_for_url(
|
||||||
|
url=primary,
|
||||||
|
private=_visibility_private_value(),
|
||||||
|
description=description,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
_print_probe_result(None, primary, cwd=ctx.repo_dir)
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Provision ALL git mirrors (if requested)
|
||||||
|
if ensure_remote:
|
||||||
|
for name, url in git_mirrors.items():
|
||||||
|
print(f"[REMOTE ENSURE] ensuring mirror {name!r}: {url}")
|
||||||
|
ensure_remote_repository_for_url(
|
||||||
|
url=url,
|
||||||
|
private_default=_desired_private_default(),
|
||||||
|
description=description,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
if _should_enforce_visibility():
|
||||||
|
_apply_visibility_for_url(
|
||||||
|
url=url,
|
||||||
|
private=_visibility_private_value(),
|
||||||
|
description=description,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Probe ALL git mirrors
|
||||||
for name, url in git_mirrors.items():
|
for name, url in git_mirrors.items():
|
||||||
ok = probe_remote_reachable(url, cwd=ctx.repo_dir)
|
_print_probe_result(name, url, cwd=ctx.repo_dir)
|
||||||
print(f"[OK] {name}: {url}" if ok else f"[WARN] {name}: {url}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -95,6 +202,7 @@ def setup_mirrors(
|
|||||||
local: bool = True,
|
local: bool = True,
|
||||||
remote: bool = True,
|
remote: bool = True,
|
||||||
ensure_remote: bool = False,
|
ensure_remote: bool = False,
|
||||||
|
ensure_visibility: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
if local:
|
if local:
|
||||||
@@ -112,4 +220,5 @@ def setup_mirrors(
|
|||||||
all_repos,
|
all_repos,
|
||||||
preview,
|
preview,
|
||||||
ensure_remote,
|
ensure_remote,
|
||||||
|
ensure_visibility,
|
||||||
)
|
)
|
||||||
@@ -17,7 +17,7 @@ def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
|
|||||||
netloc = netloc.split("@", 1)[1]
|
netloc = netloc.split("@", 1)[1]
|
||||||
|
|
||||||
if netloc.startswith("[") and "]" in netloc:
|
if netloc.startswith("[") and "]" in netloc:
|
||||||
host = netloc[1:netloc.index("]")]
|
host = netloc[1 : netloc.index("]")]
|
||||||
rest = netloc[netloc.index("]") + 1 :]
|
rest = netloc[netloc.index("]") + 1 :]
|
||||||
port = rest[1:] if rest.startswith(":") else None
|
port = rest[1:] if rest.startswith(":") else None
|
||||||
return host.strip(), (port.strip() if port else None)
|
return host.strip(), (port.strip() if port else None)
|
||||||
@@ -43,7 +43,7 @@ def normalize_provider_host(host: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
if host.startswith("[") and "]" in host:
|
if host.startswith("[") and "]" in host:
|
||||||
host = host[1:host.index("]")]
|
host = host[1 : host.index("]")]
|
||||||
|
|
||||||
if ":" in host and host.count(":") == 1:
|
if ":" in host and host.count(":") == 1:
|
||||||
host = host.rsplit(":", 1)[0]
|
host = host.rsplit(":", 1)[0]
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
|
|
||||||
|
from .context import build_context
|
||||||
|
from .git_remote import determine_primary_remote_url
|
||||||
|
from .types import Repository
|
||||||
|
from .url_utils import normalize_provider_host, parse_repo_from_git_url
|
||||||
|
|
||||||
|
|
||||||
|
def _is_git_remote_url(url: str) -> bool:
|
||||||
|
# Keep same semantics as setup_cmd.py / git_remote.py
|
||||||
|
u = (url or "").strip()
|
||||||
|
if not u:
|
||||||
|
return False
|
||||||
|
if u.startswith("git@"):
|
||||||
|
return True
|
||||||
|
if u.startswith("ssh://"):
|
||||||
|
return True
|
||||||
|
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
|
h = (host or "").lower()
|
||||||
|
if h == "github.com":
|
||||||
|
return "github"
|
||||||
|
# Best-effort default for self-hosted git domains
|
||||||
|
return "gitea" if h else None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_visibility_for_url(
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
private: bool,
|
||||||
|
description: str,
|
||||||
|
preview: bool,
|
||||||
|
) -> None:
|
||||||
|
host_raw, owner, name = parse_repo_from_git_url(url)
|
||||||
|
host = normalize_provider_host(host_raw)
|
||||||
|
|
||||||
|
if not host or not owner or not name:
|
||||||
|
print(f"[WARN] Could not parse repo from URL: {url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = RepoSpec(
|
||||||
|
host=host,
|
||||||
|
owner=owner,
|
||||||
|
name=name,
|
||||||
|
private=private,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_kind = _provider_hint_from_host(host)
|
||||||
|
res = set_repo_visibility(
|
||||||
|
spec,
|
||||||
|
private=private,
|
||||||
|
provider_hint=ProviderHint(kind=provider_kind),
|
||||||
|
options=VisibilityOptions(preview=preview),
|
||||||
|
)
|
||||||
|
print(f"[REMOTE VISIBILITY] {res.status.upper()}: {res.message}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_mirror_visibility(
|
||||||
|
selected_repos: List[Repository],
|
||||||
|
repositories_base_dir: str,
|
||||||
|
all_repos: List[Repository],
|
||||||
|
*,
|
||||||
|
visibility: str,
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set remote repository visibility for all git mirrors of each selected repo.
|
||||||
|
|
||||||
|
visibility:
|
||||||
|
- "private"
|
||||||
|
- "public"
|
||||||
|
"""
|
||||||
|
v = (visibility or "").strip().lower()
|
||||||
|
if v not in ("private", "public"):
|
||||||
|
raise ValueError("visibility must be 'private' or 'public'")
|
||||||
|
|
||||||
|
desired_private = v == "private"
|
||||||
|
|
||||||
|
for repo in selected_repos:
|
||||||
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
|
|
||||||
|
print("------------------------------------------------------------")
|
||||||
|
print(f"[MIRROR VISIBILITY] {ctx.identifier}")
|
||||||
|
print(f"[MIRROR VISIBILITY] dir: {ctx.repo_dir}")
|
||||||
|
print(f"[MIRROR VISIBILITY] target: {v}")
|
||||||
|
print("------------------------------------------------------------")
|
||||||
|
|
||||||
|
git_mirrors = {
|
||||||
|
name: url
|
||||||
|
for name, url in ctx.resolved_mirrors.items()
|
||||||
|
if url and _is_git_remote_url(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there are no git mirrors, fall back to primary (git) URL.
|
||||||
|
if not git_mirrors:
|
||||||
|
primary = determine_primary_remote_url(repo, ctx)
|
||||||
|
if not primary or not _is_git_remote_url(primary):
|
||||||
|
print(
|
||||||
|
"[INFO] No git mirrors found (and no primary git URL). Nothing to do."
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[MIRROR VISIBILITY] applying to primary: {primary}")
|
||||||
|
_apply_visibility_for_url(
|
||||||
|
url=primary,
|
||||||
|
private=desired_private,
|
||||||
|
description=str(repo.get("description", "")),
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply to ALL git mirrors
|
||||||
|
for name, url in git_mirrors.items():
|
||||||
|
print(f"[MIRROR VISIBILITY] applying to mirror {name!r}: {url}")
|
||||||
|
_apply_visibility_for_url(
|
||||||
|
url=url,
|
||||||
|
private=desired_private,
|
||||||
|
description=str(repo.get("description", "")),
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
@@ -4,7 +4,16 @@ from pkgmgr.core.repository.dir import get_repo_dir
|
|||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir, all_repos, proxy_command: str, extra_args, preview: bool):
|
|
||||||
|
def exec_proxy_command(
|
||||||
|
proxy_prefix: str,
|
||||||
|
selected_repos,
|
||||||
|
repositories_base_dir,
|
||||||
|
all_repos,
|
||||||
|
proxy_command: str,
|
||||||
|
extra_args,
|
||||||
|
preview: bool,
|
||||||
|
):
|
||||||
"""Execute a given proxy command with extra arguments for each repository."""
|
"""Execute a given proxy command with extra arguments for each repository."""
|
||||||
error_repos = []
|
error_repos = []
|
||||||
max_exit_code = 0
|
max_exit_code = 0
|
||||||
@@ -22,7 +31,9 @@ def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir,
|
|||||||
try:
|
try:
|
||||||
run_command(full_cmd, cwd=repo_dir, preview=preview)
|
run_command(full_cmd, cwd=repo_dir, preview=preview)
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
print(f"[ERROR] Command failed in {repo_identifier} with exit code {e.code}.")
|
print(
|
||||||
|
f"[ERROR] Command failed in {repo_identifier} with exit code {e.code}."
|
||||||
|
)
|
||||||
error_repos.append((repo_identifier, e.code))
|
error_repos.append((repo_identifier, e.code))
|
||||||
max_exit_code = max(max_exit_code, e.code)
|
max_exit_code = max(max_exit_code, e.code)
|
||||||
|
|
||||||
@@ -30,4 +41,4 @@ def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir,
|
|||||||
print("\nSummary of failed commands:")
|
print("\nSummary of failed commands:")
|
||||||
for repo_identifier, exit_code in error_repos:
|
for repo_identifier, exit_code in error_repos:
|
||||||
print(f"- {repo_identifier} failed with exit code {exit_code}")
|
print(f"- {repo_identifier} failed with exit code {exit_code}")
|
||||||
sys.exit(max_exit_code)
|
sys.exit(max_exit_code)
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
File and metadata update helpers for the release workflow.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Update pyproject.toml with the new version.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import get_config_value
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Editor helper for interactive changelog messages
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
Open $EDITOR (fallback 'nano') so the user can enter a changelog message.
|
|
||||||
|
|
||||||
The temporary file is pre-filled with commented instructions and an
|
|
||||||
optional initial_message. Lines starting with '#' are ignored when the
|
|
||||||
message is read back.
|
|
||||||
|
|
||||||
Returns the final message (may be empty string if user leaves it blank).
|
|
||||||
"""
|
|
||||||
editor = os.environ.get("EDITOR", "nano")
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w+",
|
|
||||||
delete=False,
|
|
||||||
encoding="utf-8",
|
|
||||||
) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
tmp.write(
|
|
||||||
"# Write the changelog entry for this release.\n"
|
|
||||||
"# Lines starting with '#' will be ignored.\n"
|
|
||||||
"# Empty result will fall back to a generic message.\n\n"
|
|
||||||
)
|
|
||||||
if initial_message:
|
|
||||||
tmp.write(initial_message.strip() + "\n")
|
|
||||||
tmp.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.call([editor, tmp_path])
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(
|
|
||||||
f"[WARN] Editor {editor!r} not found; proceeding without "
|
|
||||||
"interactive changelog message."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(tmp_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# File update helpers (pyproject + extra packaging + changelog)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def update_pyproject_version(
|
|
||||||
pyproject_path: str,
|
|
||||||
new_version: str,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Update the version in pyproject.toml with the new version.
|
|
||||||
|
|
||||||
The function looks for a line matching:
|
|
||||||
|
|
||||||
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 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(
|
|
||||||
pattern,
|
|
||||||
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
|
|
||||||
content,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print("[ERROR] Could not find version line in pyproject.toml")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(pyproject_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
|
|
||||||
print(f"Updated pyproject.toml version to {new_version}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_flake_version(
|
|
||||||
flake_path: str,
|
|
||||||
new_version: str,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Update the version in flake.nix, if present.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(flake_path):
|
|
||||||
print("[INFO] flake.nix not found, skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(flake_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not read flake.nix: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
pattern = r'(version\s*=\s*")([^"]+)(")'
|
|
||||||
new_content, count = re.subn(
|
|
||||||
pattern,
|
|
||||||
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print("[WARN] No version assignment found in flake.nix, skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(flake_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
|
|
||||||
print(f"Updated flake.nix version to {new_version}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_pkgbuild_version(
|
|
||||||
pkgbuild_path: str,
|
|
||||||
new_version: str,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Update the version in PKGBUILD, if present.
|
|
||||||
|
|
||||||
Expects:
|
|
||||||
pkgver=1.2.3
|
|
||||||
pkgrel=1
|
|
||||||
"""
|
|
||||||
if not os.path.exists(pkgbuild_path):
|
|
||||||
print("[INFO] PKGBUILD not found, skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not read PKGBUILD: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
|
|
||||||
new_content, ver_count = re.subn(
|
|
||||||
ver_pattern,
|
|
||||||
lambda m: f"{m.group(1)}{new_version}",
|
|
||||||
content,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if ver_count == 0:
|
|
||||||
print("[WARN] No pkgver line found in PKGBUILD.")
|
|
||||||
new_content = content
|
|
||||||
|
|
||||||
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
|
|
||||||
new_content, rel_count = re.subn(
|
|
||||||
rel_pattern,
|
|
||||||
lambda m: f"{m.group(1)}1",
|
|
||||||
new_content,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if rel_count == 0:
|
|
||||||
print("[WARN] No pkgrel line found in PKGBUILD.")
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(pkgbuild_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
|
|
||||||
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
|
|
||||||
|
|
||||||
|
|
||||||
def update_spec_version(
|
|
||||||
spec_path: str,
|
|
||||||
new_version: str,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Update the version in an RPM spec file, if present.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(spec_path):
|
|
||||||
print("[INFO] RPM spec file not found, skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
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: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
ver_pattern = r"^(Version:\s*)(.+)$"
|
|
||||||
new_content, ver_count = re.subn(
|
|
||||||
ver_pattern,
|
|
||||||
lambda m: f"{m.group(1)}{new_version}",
|
|
||||||
content,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if ver_count == 0:
|
|
||||||
print("[WARN] No 'Version:' line found in spec file.")
|
|
||||||
|
|
||||||
rel_pattern = r"^(Release:\s*)(.+)$"
|
|
||||||
|
|
||||||
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
|
|
||||||
rest = m.group(2).strip()
|
|
||||||
match = re.match(r"^(\d+)(.*)$", rest)
|
|
||||||
if match:
|
|
||||||
suffix = match.group(2)
|
|
||||||
else:
|
|
||||||
suffix = ""
|
|
||||||
return f"{m.group(1)}1{suffix}"
|
|
||||||
|
|
||||||
new_content, rel_count = re.subn(
|
|
||||||
rel_pattern,
|
|
||||||
_release_repl,
|
|
||||||
new_content,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if rel_count == 0:
|
|
||||||
print("[WARN] No 'Release:' line found in spec file.")
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(
|
|
||||||
"[PREVIEW] Would update spec file "
|
|
||||||
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(spec_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Updated spec file {os.path.basename(spec_path)} "
|
|
||||||
f"to Version: {new_version}, Release: 1..."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_changelog(
|
|
||||||
changelog_path: str,
|
|
||||||
new_version: str,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Prepend a new release section to CHANGELOG.md with the new version,
|
|
||||||
current date, and a message.
|
|
||||||
"""
|
|
||||||
today = date.today().isoformat()
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
if preview:
|
|
||||||
message = "Automated release."
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"\n[INFO] No release message provided, opening editor for "
|
|
||||||
"changelog entry...\n"
|
|
||||||
)
|
|
||||||
editor_message = _open_editor_for_changelog()
|
|
||||||
if not editor_message:
|
|
||||||
message = "Automated release."
|
|
||||||
else:
|
|
||||||
message = editor_message
|
|
||||||
|
|
||||||
header = f"## [{new_version}] - {today}\n"
|
|
||||||
header += f"\n* {message}\n\n"
|
|
||||||
|
|
||||||
if os.path.exists(changelog_path):
|
|
||||||
try:
|
|
||||||
with open(changelog_path, "r", encoding="utf-8") as f:
|
|
||||||
changelog = f.read()
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
|
||||||
changelog = ""
|
|
||||||
else:
|
|
||||||
changelog = ""
|
|
||||||
|
|
||||||
new_changelog = header + "\n" + changelog if changelog else header
|
|
||||||
|
|
||||||
print("\n================ CHANGELOG ENTRY ================")
|
|
||||||
print(header.rstrip())
|
|
||||||
print("=================================================\n")
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
|
|
||||||
return message
|
|
||||||
|
|
||||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_changelog)
|
|
||||||
|
|
||||||
print(f"Updated CHANGELOG.md with version {new_version}")
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Debian changelog helpers (with Git config fallback for maintainer)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _get_debian_author() -> Tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Determine the maintainer name/email for debian/changelog entries.
|
|
||||||
"""
|
|
||||||
name = os.environ.get("DEBFULLNAME")
|
|
||||||
email = os.environ.get("DEBEMAIL")
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
name = os.environ.get("GIT_AUTHOR_NAME")
|
|
||||||
if not email:
|
|
||||||
email = os.environ.get("GIT_AUTHOR_EMAIL")
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
name = get_config_value("user.name")
|
|
||||||
if not email:
|
|
||||||
email = get_config_value("user.email")
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
name = "Unknown Maintainer"
|
|
||||||
if not email:
|
|
||||||
email = "unknown@example.com"
|
|
||||||
|
|
||||||
return name, email
|
|
||||||
|
|
||||||
|
|
||||||
def update_debian_changelog(
|
|
||||||
debian_changelog_path: str,
|
|
||||||
package_name: str,
|
|
||||||
new_version: str,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Prepend a new entry to debian/changelog, if it exists.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(debian_changelog_path):
|
|
||||||
print("[INFO] debian/changelog not found, skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
debian_version = f"{new_version}-1"
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
||||||
|
|
||||||
author_name, author_email = _get_debian_author()
|
|
||||||
|
|
||||||
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
|
|
||||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
|
||||||
stanza = (
|
|
||||||
f"{first_line}\n\n"
|
|
||||||
f" * {body_line}\n\n"
|
|
||||||
f" -- {author_name} <{author_email}> {date_str}\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
print(
|
|
||||||
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
|
|
||||||
f"{stanza}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(debian_changelog_path, "r", encoding="utf-8") as f:
|
|
||||||
existing = f.read()
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not read debian/changelog: {exc}")
|
|
||||||
existing = ""
|
|
||||||
|
|
||||||
new_content = stanza + existing
|
|
||||||
|
|
||||||
with open(debian_changelog_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
|
|
||||||
print(f"Updated debian/changelog with version {debian_version}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fedora / RPM spec %changelog helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def update_spec_changelog(
|
|
||||||
spec_path: str,
|
|
||||||
package_name: str,
|
|
||||||
new_version: str,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
preview: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
if not os.path.exists(spec_path):
|
|
||||||
print("[INFO] RPM spec file not found, skipping spec changelog update.")
|
|
||||||
return
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
debian_version = f"{new_version}-1"
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
date_str = now.strftime("%a %b %d %Y")
|
|
||||||
|
|
||||||
# Reuse Debian maintainer discovery for author name/email.
|
|
||||||
author_name, author_email = _get_debian_author()
|
|
||||||
|
|
||||||
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(
|
|
||||||
"[PREVIEW] Would update RPM %changelog section with the following "
|
|
||||||
"stanza:\n"
|
|
||||||
f"{stanza}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
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
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
|
|
||||||
f"for {package_name} {debian_version}"
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Backwards-compatible facade for the release file update helpers.
|
||||||
|
|
||||||
|
Implementations live in this package:
|
||||||
|
pkgmgr.actions.release.files.*
|
||||||
|
|
||||||
|
Keep this package stable so existing imports continue to work, e.g.:
|
||||||
|
from pkgmgr.actions.release.files import update_pyproject_version
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .editor import _open_editor_for_changelog
|
||||||
|
from .pyproject import update_pyproject_version
|
||||||
|
from .flake import update_flake_version
|
||||||
|
from .pkgbuild import update_pkgbuild_version
|
||||||
|
from .rpm_spec import update_spec_version
|
||||||
|
from .changelog_md import update_changelog
|
||||||
|
from .debian import _get_debian_author, update_debian_changelog
|
||||||
|
from .rpm_changelog import update_spec_changelog
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_open_editor_for_changelog",
|
||||||
|
"update_pyproject_version",
|
||||||
|
"update_flake_version",
|
||||||
|
"update_pkgbuild_version",
|
||||||
|
"update_spec_version",
|
||||||
|
"update_changelog",
|
||||||
|
"_get_debian_author",
|
||||||
|
"update_debian_changelog",
|
||||||
|
"update_spec_changelog",
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .editor import _open_editor_for_changelog
|
||||||
|
|
||||||
|
|
||||||
|
def update_changelog(
|
||||||
|
changelog_path: str,
|
||||||
|
new_version: str,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Prepend a new release section to CHANGELOG.md with the new version,
|
||||||
|
current date, and a message.
|
||||||
|
"""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
if preview:
|
||||||
|
message = "Automated release."
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
|
||||||
|
)
|
||||||
|
editor_message = _open_editor_for_changelog()
|
||||||
|
if not editor_message:
|
||||||
|
message = "Automated release."
|
||||||
|
else:
|
||||||
|
message = editor_message
|
||||||
|
|
||||||
|
header = f"## [{new_version}] - {today}\n"
|
||||||
|
header += f"\n* {message}\n\n"
|
||||||
|
|
||||||
|
if os.path.exists(changelog_path):
|
||||||
|
try:
|
||||||
|
with open(changelog_path, "r", encoding="utf-8") as f:
|
||||||
|
changelog = f.read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||||
|
changelog = ""
|
||||||
|
else:
|
||||||
|
changelog = ""
|
||||||
|
|
||||||
|
new_changelog = header + "\n" + changelog if changelog else header
|
||||||
|
|
||||||
|
print("\n================ CHANGELOG ENTRY ================")
|
||||||
|
print(header.rstrip())
|
||||||
|
print("=================================================\n")
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
|
||||||
|
return message
|
||||||
|
|
||||||
|
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_changelog)
|
||||||
|
|
||||||
|
print(f"Updated CHANGELOG.md with version {new_version}")
|
||||||
|
return message
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from pkgmgr.core.git.queries import get_config_value
|
||||||
|
|
||||||
|
|
||||||
|
def _get_debian_author() -> Tuple[str, str]:
|
||||||
|
name = os.environ.get("DEBFULLNAME")
|
||||||
|
email = os.environ.get("DEBEMAIL")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = os.environ.get("GIT_AUTHOR_NAME")
|
||||||
|
if not email:
|
||||||
|
email = os.environ.get("GIT_AUTHOR_EMAIL")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = get_config_value("user.name")
|
||||||
|
if not email:
|
||||||
|
email = get_config_value("user.email")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = "Unknown Maintainer"
|
||||||
|
if not email:
|
||||||
|
email = "unknown@example.com"
|
||||||
|
|
||||||
|
return name, email
|
||||||
|
|
||||||
|
|
||||||
|
def update_debian_changelog(
|
||||||
|
debian_changelog_path: str,
|
||||||
|
package_name: str,
|
||||||
|
new_version: str,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not os.path.exists(debian_changelog_path):
|
||||||
|
print("[INFO] debian/changelog not found, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
debian_version = f"{new_version}-1"
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
|
||||||
|
|
||||||
|
author_name, author_email = _get_debian_author()
|
||||||
|
|
||||||
|
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
|
||||||
|
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||||
|
stanza = (
|
||||||
|
f"{first_line}\n\n"
|
||||||
|
f" * {body_line}\n\n"
|
||||||
|
f" -- {author_name} <{author_email}> {date_str}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(
|
||||||
|
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
|
||||||
|
f"{stanza}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(debian_changelog_path, "r", encoding="utf-8") as f:
|
||||||
|
existing = f.read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not read debian/changelog: {exc}")
|
||||||
|
existing = ""
|
||||||
|
|
||||||
|
with open(debian_changelog_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(stanza + existing)
|
||||||
|
|
||||||
|
print(f"Updated debian/changelog with version {debian_version}")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||||
|
editor = os.environ.get("EDITOR", "nano")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w+",
|
||||||
|
delete=False,
|
||||||
|
encoding="utf-8",
|
||||||
|
) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
tmp.write(
|
||||||
|
"# Write the changelog entry for this release.\n"
|
||||||
|
"# Lines starting with '#' will be ignored.\n"
|
||||||
|
"# Empty result will fall back to a generic message.\n\n"
|
||||||
|
)
|
||||||
|
if initial_message:
|
||||||
|
tmp.write(initial_message.strip() + "\n")
|
||||||
|
tmp.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.call([editor, tmp_path])
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(
|
||||||
|
f"[WARN] Editor {editor!r} not found; proceeding without "
|
||||||
|
"interactive changelog message."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
|
||||||
|
return "\n".join(lines).strip()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def update_flake_version(
|
||||||
|
flake_path: str, new_version: str, preview: bool = False
|
||||||
|
) -> None:
|
||||||
|
if not os.path.exists(flake_path):
|
||||||
|
print("[INFO] flake.nix not found, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(flake_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not read flake.nix: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
pattern = r'(version\s*=\s*")([^"]+)(")'
|
||||||
|
new_content, count = re.subn(
|
||||||
|
pattern,
|
||||||
|
lambda m: f"{m.group(1)}{new_version}{m.group(3)}",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
print("[WARN] No version found in flake.nix.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(flake_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_content)
|
||||||
|
|
||||||
|
print(f"Updated flake.nix version to {new_version}")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def update_pkgbuild_version(
|
||||||
|
pkgbuild_path: str, new_version: str, preview: bool = False
|
||||||
|
) -> None:
|
||||||
|
if not os.path.exists(pkgbuild_path):
|
||||||
|
print("[INFO] PKGBUILD not found, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not read PKGBUILD: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
content, _ = re.subn(
|
||||||
|
r"^(pkgver\s*=\s*)(.+)$",
|
||||||
|
lambda m: f"{m.group(1)}{new_version}",
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
content, _ = re.subn(
|
||||||
|
r"^(pkgrel\s*=\s*)(.+)$",
|
||||||
|
lambda m: f"{m.group(1)}1",
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(pkgbuild_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def update_pyproject_version(
|
||||||
|
pyproject_path: str, new_version: str, preview: bool = False
|
||||||
|
) -> None:
|
||||||
|
if not os.path.exists(pyproject_path):
|
||||||
|
print(f"[INFO] pyproject.toml not found at: {pyproject_path}, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError("Missing [project] section in pyproject.toml")
|
||||||
|
|
||||||
|
project_block = m.group(0)
|
||||||
|
ver_pat = r'(?m)^(\s*version\s*=\s*")([^"]+)(")\s*$'
|
||||||
|
|
||||||
|
new_block, count = re.subn(
|
||||||
|
ver_pat,
|
||||||
|
lambda mm: f"{mm.group(1)}{new_version}{mm.group(3)}",
|
||||||
|
project_block,
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
raise RuntimeError("Missing version key in [project] section")
|
||||||
|
|
||||||
|
new_content = content[: m.start()] + new_block + content[m.end() :]
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(pyproject_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_content)
|
||||||
|
|
||||||
|
print(f"Updated pyproject.toml version to {new_version}")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .debian import _get_debian_author
|
||||||
|
|
||||||
|
|
||||||
|
def update_spec_changelog(
|
||||||
|
spec_path: str,
|
||||||
|
package_name: str,
|
||||||
|
new_version: str,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not os.path.exists(spec_path):
|
||||||
|
print("[INFO] RPM spec file not found, skipping spec changelog update.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
debian_version = f"{new_version}-1"
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
date_str = now.strftime("%a %b %d %Y")
|
||||||
|
|
||||||
|
author_name, author_email = _get_debian_author()
|
||||||
|
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:
|
||||||
|
new_content = content.rstrip() + "\n\n%changelog\n" + stanza
|
||||||
|
else:
|
||||||
|
before = content[: idx + len(marker)]
|
||||||
|
after = content[idx + len(marker) :]
|
||||||
|
new_content = before + "\n" + stanza + after.lstrip("\n")
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(
|
||||||
|
"[PREVIEW] Would update RPM %changelog section with the following stanza:\n"
|
||||||
|
f"{stanza}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
|
||||||
|
f"for {package_name} {debian_version}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def update_spec_version(
|
||||||
|
spec_path: str, new_version: str, preview: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update the version in an RPM spec file, if present.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(spec_path):
|
||||||
|
print("[INFO] RPM spec file not found, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
ver_pattern = r"^(Version:\s*)(.+)$"
|
||||||
|
new_content, ver_count = re.subn(
|
||||||
|
ver_pattern,
|
||||||
|
lambda m: f"{m.group(1)}{new_version}",
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ver_count == 0:
|
||||||
|
print("[WARN] No 'Version:' line found in spec file.")
|
||||||
|
|
||||||
|
rel_pattern = r"^(Release:\s*)(.+)$"
|
||||||
|
|
||||||
|
def _release_repl(m: re.Match[str]) -> str:
|
||||||
|
rest = m.group(2).strip()
|
||||||
|
match = re.match(r"^(\d+)(.*)$", rest)
|
||||||
|
suffix = match.group(2) if match else ""
|
||||||
|
return f"{m.group(1)}1{suffix}"
|
||||||
|
|
||||||
|
new_content, rel_count = re.subn(
|
||||||
|
rel_pattern,
|
||||||
|
_release_repl,
|
||||||
|
new_content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if rel_count == 0:
|
||||||
|
print("[WARN] No 'Release:' line found in spec file.")
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(
|
||||||
|
"[PREVIEW] Would update spec file "
|
||||||
|
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(spec_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_content)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Updated spec file {os.path.basename(spec_path)} "
|
||||||
|
f"to Version: {new_version}, Release: 1..."
|
||||||
|
)
|
||||||
@@ -80,7 +80,9 @@ def is_highest_version_tag(tag: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
latest = max(parsed_all)
|
latest = max(parsed_all)
|
||||||
print(f"[INFO] Latest tag (parsed): v{'.'.join(map(str, latest))}, Current tag: {tag}")
|
print(
|
||||||
|
f"[INFO] Latest tag (parsed): v{'.'.join(map(str, latest))}, Current tag: {tag}"
|
||||||
|
)
|
||||||
return parsed_current >= latest
|
return parsed_current >= latest
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +95,9 @@ def update_latest_tag(new_tag: str, *, preview: bool = False) -> None:
|
|||||||
- 'latest' is forced (floating tag), therefore the push uses --force.
|
- 'latest' is forced (floating tag), therefore the push uses --force.
|
||||||
"""
|
"""
|
||||||
target_ref = f"{new_tag}^{{}}"
|
target_ref = f"{new_tag}^{{}}"
|
||||||
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
print(
|
||||||
|
f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})..."
|
||||||
|
)
|
||||||
|
|
||||||
tag_force_annotated(
|
tag_force_annotated(
|
||||||
name="latest",
|
name="latest",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import sys
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.actions.branch import close_branch
|
from pkgmgr.actions.branch import close_branch
|
||||||
from pkgmgr.core.git import GitError
|
from pkgmgr.core.git import GitRunError, run
|
||||||
from pkgmgr.core.git.commands import add, commit, push, tag_annotated
|
from pkgmgr.core.git.commands import add, commit, tag_annotated
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
from pkgmgr.core.git.queries import get_current_branch
|
||||||
from pkgmgr.core.repository.paths import resolve_repo_paths
|
from pkgmgr.core.repository.paths import resolve_repo_paths
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ def _release_impl(
|
|||||||
# Determine current branch early
|
# Determine current branch early
|
||||||
try:
|
try:
|
||||||
branch = get_current_branch() or "main"
|
branch = get_current_branch() or "main"
|
||||||
except GitError:
|
except GitRunError:
|
||||||
branch = "main"
|
branch = "main"
|
||||||
print(f"Releasing on branch: {branch}")
|
print(f"Releasing on branch: {branch}")
|
||||||
|
|
||||||
@@ -76,7 +76,9 @@ def _release_impl(
|
|||||||
if paths.arch_pkgbuild:
|
if paths.arch_pkgbuild:
|
||||||
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
|
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
|
||||||
else:
|
else:
|
||||||
print("[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping.")
|
print(
|
||||||
|
"[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping."
|
||||||
|
)
|
||||||
|
|
||||||
if paths.rpm_spec:
|
if paths.rpm_spec:
|
||||||
update_spec_version(paths.rpm_spec, new_ver_str, preview=preview)
|
update_spec_version(paths.rpm_spec, new_ver_str, preview=preview)
|
||||||
@@ -123,42 +125,48 @@ def _release_impl(
|
|||||||
paths.rpm_spec,
|
paths.rpm_spec,
|
||||||
paths.debian_changelog,
|
paths.debian_changelog,
|
||||||
]
|
]
|
||||||
existing_files = [p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)]
|
existing_files = [
|
||||||
|
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
|
||||||
|
]
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
add(existing_files, preview=True)
|
add(existing_files, preview=True)
|
||||||
commit(commit_msg, all=True, preview=True)
|
commit(commit_msg, all=True, preview=True)
|
||||||
tag_annotated(new_tag, tag_msg, preview=True)
|
tag_annotated(new_tag, tag_msg, preview=True)
|
||||||
push("origin", branch, preview=True)
|
run(["push", "origin", branch, new_tag], preview=True)
|
||||||
push("origin", new_tag, preview=True)
|
|
||||||
|
|
||||||
if is_highest_version_tag(new_tag):
|
if is_highest_version_tag(new_tag):
|
||||||
update_latest_tag(new_tag, preview=True)
|
update_latest_tag(new_tag, preview=True)
|
||||||
else:
|
else:
|
||||||
print(f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
print(
|
||||||
|
f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest)."
|
||||||
|
)
|
||||||
|
|
||||||
if close and branch not in ("main", "master"):
|
if close and branch not in ("main", "master"):
|
||||||
if force:
|
if force:
|
||||||
print(f"[PREVIEW] Would delete branch {branch} (forced).")
|
print(f"[PREVIEW] Would delete branch {branch} (forced).")
|
||||||
else:
|
else:
|
||||||
print(f"[PREVIEW] Would ask whether to delete branch {branch} after release.")
|
print(
|
||||||
|
f"[PREVIEW] Would ask whether to delete branch {branch} after release."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
add(existing_files, preview=False)
|
add(existing_files, preview=False)
|
||||||
commit(commit_msg, all=True, preview=False)
|
commit(commit_msg, all=True, preview=False)
|
||||||
tag_annotated(new_tag, tag_msg, preview=False)
|
tag_annotated(new_tag, tag_msg, preview=False)
|
||||||
|
|
||||||
# Push branch and ONLY the newly created version tag (no --tags)
|
# Push branch and ONLY the newly created version tag in one command (no --tags)
|
||||||
push("origin", branch, preview=False)
|
run(["push", "origin", branch, new_tag], preview=False)
|
||||||
push("origin", new_tag, preview=False)
|
|
||||||
|
|
||||||
# Update 'latest' only if this is the highest version tag
|
# Update 'latest' only if this is the highest version tag
|
||||||
try:
|
try:
|
||||||
if is_highest_version_tag(new_tag):
|
if is_highest_version_tag(new_tag):
|
||||||
update_latest_tag(new_tag, preview=False)
|
update_latest_tag(new_tag, preview=False)
|
||||||
else:
|
else:
|
||||||
print(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
print(
|
||||||
except GitError as exc:
|
f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest)."
|
||||||
|
)
|
||||||
|
except GitRunError as exc:
|
||||||
print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}")
|
print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}")
|
||||||
print("'latest' tag was not updated.")
|
print("'latest' tag was not updated.")
|
||||||
|
|
||||||
@@ -166,7 +174,9 @@ def _release_impl(
|
|||||||
|
|
||||||
if close:
|
if close:
|
||||||
if branch in ("main", "master"):
|
if branch in ("main", "master"):
|
||||||
print(f"[INFO] close=True but current branch is {branch}; skipping branch deletion.")
|
print(
|
||||||
|
f"[INFO] close=True but current branch is {branch}; skipping branch deletion."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not should_delete_branch(force=force):
|
if not should_delete_branch(force=force):
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ def clone_repos(
|
|||||||
|
|
||||||
clone_url = _build_clone_url(repo, clone_mode)
|
clone_url = _build_clone_url(repo, clone_mode)
|
||||||
if not clone_url:
|
if not clone_url:
|
||||||
print(f"[WARNING] Cannot build clone URL for '{repo_identifier}'. Skipping.")
|
print(
|
||||||
|
f"[WARNING] Cannot build clone URL for '{repo_identifier}'. Skipping."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
shallow = clone_mode == "shallow"
|
shallow = clone_mode == "shallow"
|
||||||
@@ -84,7 +86,11 @@ def clone_repos(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"[WARNING] SSH clone failed for '{repo_identifier}': {exc}")
|
print(f"[WARNING] SSH clone failed for '{repo_identifier}': {exc}")
|
||||||
choice = input("Do you want to attempt HTTPS clone instead? (y/N): ").strip().lower()
|
choice = (
|
||||||
|
input("Do you want to attempt HTTPS clone instead? (y/N): ")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if choice != "y":
|
if choice != "y":
|
||||||
print(f"[INFO] HTTPS clone not attempted for '{repo_identifier}'.")
|
print(f"[INFO] HTTPS clone not attempted for '{repo_identifier}'.")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ class MirrorBootstrapper:
|
|||||||
"""
|
"""
|
||||||
MIRRORS is the single source of truth.
|
MIRRORS is the single source of truth.
|
||||||
|
|
||||||
We write defaults to MIRRORS and then call mirror setup which will
|
Defaults are written to MIRRORS and mirror setup derives
|
||||||
configure git remotes based on MIRRORS content (but only for git URLs).
|
git remotes exclusively from that file (git URLs only).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def write_defaults(
|
def write_defaults(
|
||||||
@@ -25,10 +25,8 @@ class MirrorBootstrapper:
|
|||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
mirrors = {
|
mirrors = {
|
||||||
# preferred SSH url is supplied by CreateRepoPlanner.primary_remote
|
primary,
|
||||||
"origin": primary,
|
f"https://pypi.org/project/{name}/",
|
||||||
# metadata only: must NEVER be configured as a git remote
|
|
||||||
"pypi": f"https://pypi.org/project/{name}/",
|
|
||||||
}
|
}
|
||||||
write_mirrors_file(repo_dir, mirrors, preview=preview)
|
write_mirrors_file(repo_dir, mirrors, preview=preview)
|
||||||
|
|
||||||
@@ -41,7 +39,8 @@ class MirrorBootstrapper:
|
|||||||
preview: bool,
|
preview: bool,
|
||||||
remote: bool,
|
remote: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
# IMPORTANT: do NOT set repo["mirrors"] here.
|
# IMPORTANT:
|
||||||
|
# Do NOT set repo["mirrors"] here.
|
||||||
# MIRRORS file is the single source of truth.
|
# MIRRORS file is the single source of truth.
|
||||||
setup_mirrors(
|
setup_mirrors(
|
||||||
selected_repos=[repo],
|
selected_repos=[repo],
|
||||||
|
|||||||
@@ -63,6 +63,4 @@ def _strip_git_suffix(name: str) -> str:
|
|||||||
|
|
||||||
def _ensure_valid_repo_name(name: str) -> None:
|
def _ensure_valid_repo_name(name: str) -> None:
|
||||||
if not _NAME_RE.fullmatch(name):
|
if not _NAME_RE.fullmatch(name):
|
||||||
raise ValueError(
|
raise ValueError("Repository name must match: lowercase a-z, 0-9, '_' and '-'.")
|
||||||
"Repository name must match: lowercase a-z, 0-9, '_' and '-'."
|
|
||||||
)
|
|
||||||
@@ -66,9 +66,7 @@ class TemplateRenderer:
|
|||||||
for root, _, files in os.walk(self.templates_dir):
|
for root, _, files in os.walk(self.templates_dir):
|
||||||
for fn in files:
|
for fn in files:
|
||||||
if fn.endswith(".j2"):
|
if fn.endswith(".j2"):
|
||||||
rel = os.path.relpath(
|
rel = os.path.relpath(os.path.join(root, fn), self.templates_dir)
|
||||||
os.path.join(root, fn), self.templates_dir
|
|
||||||
)
|
|
||||||
print(f"[Preview] Would render template: {rel} -> {rel[:-3]}")
|
print(f"[Preview] Would render template: {rel} -> {rel[:-3]}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ def deinstall_repos(
|
|||||||
|
|
||||||
# Remove alias link/file (interactive)
|
# Remove alias link/file (interactive)
|
||||||
if os.path.exists(alias_path):
|
if os.path.exists(alias_path):
|
||||||
confirm = input(
|
confirm = (
|
||||||
f"Are you sure you want to delete link '{alias_path}' for {repo_identifier}? [y/N]: "
|
input(
|
||||||
).strip().lower()
|
f"Are you sure you want to delete link '{alias_path}' for {repo_identifier}? [y/N]: "
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if confirm == "y":
|
if confirm == "y":
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[Preview] Would remove link '{alias_path}'.")
|
print(f"[Preview] Would remove link '{alias_path}'.")
|
||||||
|
|||||||
@@ -3,22 +3,33 @@ import os
|
|||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
|
|
||||||
def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False):
|
def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False):
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
repo_identifier = get_repo_identifier(repo, all_repos)
|
||||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||||
if os.path.exists(repo_dir):
|
if os.path.exists(repo_dir):
|
||||||
confirm = input(f"Are you sure you want to delete directory '{repo_dir}' for {repo_identifier}? [y/N]: ").strip().lower()
|
confirm = (
|
||||||
|
input(
|
||||||
|
f"Are you sure you want to delete directory '{repo_dir}' for {repo_identifier}? [y/N]: "
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if confirm == "y":
|
if confirm == "y":
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[Preview] Would delete directory '{repo_dir}' for {repo_identifier}.")
|
print(
|
||||||
|
f"[Preview] Would delete directory '{repo_dir}' for {repo_identifier}."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(repo_dir)
|
shutil.rmtree(repo_dir)
|
||||||
print(f"Deleted repository directory '{repo_dir}' for {repo_identifier}.")
|
print(
|
||||||
|
f"Deleted repository directory '{repo_dir}' for {repo_identifier}."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting '{repo_dir}' for {repo_identifier}: {e}")
|
print(f"Error deleting '{repo_dir}' for {repo_identifier}: {e}")
|
||||||
else:
|
else:
|
||||||
print(f"Skipped deletion of '{repo_dir}' for {repo_identifier}.")
|
print(f"Skipped deletion of '{repo_dir}' for {repo_identifier}.")
|
||||||
else:
|
else:
|
||||||
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
|
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
|
||||||
@@ -233,9 +233,7 @@ def list_repositories(
|
|||||||
categories.append(str(repo["category"]))
|
categories.append(str(repo["category"]))
|
||||||
|
|
||||||
yaml_tags: List[str] = list(map(str, repo.get("tags", [])))
|
yaml_tags: List[str] = list(map(str, repo.get("tags", [])))
|
||||||
display_tags: List[str] = sorted(
|
display_tags: List[str] = sorted(set(yaml_tags + list(map(str, extra_tags))))
|
||||||
set(yaml_tags + list(map(str, extra_tags)))
|
|
||||||
)
|
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -288,13 +286,7 @@ def list_repositories(
|
|||||||
status_padded = status.ljust(status_width)
|
status_padded = status.ljust(status_width)
|
||||||
status_colored = _color_status(status_padded)
|
status_colored = _color_status(status_padded)
|
||||||
|
|
||||||
print(
|
print(f"{ident_col} {status_colored} {cat_col} {tag_col} {dir_col}")
|
||||||
f"{ident_col} "
|
|
||||||
f"{status_colored} "
|
|
||||||
f"{cat_col} "
|
|
||||||
f"{tag_col} "
|
|
||||||
f"{dir_col}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Detailed section (alias value red, same status coloring)
|
# Detailed section (alias value red, same status coloring)
|
||||||
|
|||||||
@@ -55,12 +55,16 @@ class UpdateManager:
|
|||||||
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||||
failures.append((identifier, f"pull failed (exit={code})"))
|
failures.append((identifier, f"pull failed (exit={code})"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing...")
|
print(
|
||||||
|
f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures.append((identifier, f"pull failed: {exc}"))
|
failures.append((identifier, f"pull failed: {exc}"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] update: pull failed for {identifier}: {exc}. Continuing...")
|
print(
|
||||||
|
f"[Warning] update: pull failed for {identifier}: {exc}. Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -82,12 +86,16 @@ class UpdateManager:
|
|||||||
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||||
failures.append((identifier, f"install failed (exit={code})"))
|
failures.append((identifier, f"install failed (exit={code})"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] update: install failed for {identifier} (exit={code}). Continuing...")
|
print(
|
||||||
|
f"[Warning] update: install failed for {identifier} (exit={code}). Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures.append((identifier, f"install failed: {exc}"))
|
failures.append((identifier, f"install failed: {exc}"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"[Warning] update: install failed for {identifier}: {exc}. Continuing...")
|
print(
|
||||||
|
f"[Warning] update: install failed for {identifier}: {exc}. Continuing..."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if failures and not quiet:
|
if failures and not quiet:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class OSReleaseInfo:
|
|||||||
"""
|
"""
|
||||||
Minimal /etc/os-release representation for distro detection.
|
Minimal /etc/os-release representation for distro detection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
id_like: str = ""
|
id_like: str = ""
|
||||||
pretty_name: str = ""
|
pretty_name: str = ""
|
||||||
@@ -63,4 +64,6 @@ class OSReleaseInfo:
|
|||||||
|
|
||||||
def is_fedora_family(self) -> bool:
|
def is_fedora_family(self) -> bool:
|
||||||
ids = self.ids()
|
ids = self.ids()
|
||||||
return bool(ids.intersection({"fedora", "rhel", "centos", "rocky", "almalinux"}))
|
return bool(
|
||||||
|
ids.intersection({"fedora", "rhel", "centos", "rocky", "almalinux"})
|
||||||
|
)
|
||||||
@@ -58,7 +58,9 @@ class SystemUpdater:
|
|||||||
run_command("sudo pacman -Syu --noconfirm", preview=preview)
|
run_command("sudo pacman -Syu --noconfirm", preview=preview)
|
||||||
return
|
return
|
||||||
|
|
||||||
print("[Warning] Cannot update Arch system: missing required tools (sudo/yay/pacman).")
|
print(
|
||||||
|
"[Warning] Cannot update Arch system: missing required tools (sudo/yay/pacman)."
|
||||||
|
)
|
||||||
|
|
||||||
def _update_debian(self, *, preview: bool) -> None:
|
def _update_debian(self, *, preview: bool) -> None:
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
@@ -67,7 +69,9 @@ class SystemUpdater:
|
|||||||
apt_get = shutil.which("apt-get")
|
apt_get = shutil.which("apt-get")
|
||||||
|
|
||||||
if not (sudo and apt_get):
|
if not (sudo and apt_get):
|
||||||
print("[Warning] Cannot update Debian/Ubuntu system: missing required tools (sudo/apt-get).")
|
print(
|
||||||
|
"[Warning] Cannot update Debian/Ubuntu system: missing required tools (sudo/apt-get)."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
env = "DEBIAN_FRONTEND=noninteractive"
|
env = "DEBIAN_FRONTEND=noninteractive"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ For details on any command, run:
|
|||||||
\033[1mpkgmgr <command> --help\033[0m
|
\033[1mpkgmgr <command> --help\033[0m
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""
|
"""
|
||||||
Entry point for the pkgmgr CLI.
|
Entry point for the pkgmgr CLI.
|
||||||
@@ -41,9 +42,7 @@ def main() -> None:
|
|||||||
repositories_dir = os.path.expanduser(
|
repositories_dir = os.path.expanduser(
|
||||||
directories.get("repositories", "~/Repositories")
|
directories.get("repositories", "~/Repositories")
|
||||||
)
|
)
|
||||||
binaries_dir = os.path.expanduser(
|
binaries_dir = os.path.expanduser(directories.get("binaries", "~/.local/bin"))
|
||||||
directories.get("binaries", "~/.local/bin")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure the merged config actually contains the resolved directories
|
# Ensure the merged config actually contains the resolved directories
|
||||||
config_merged.setdefault("directories", {})
|
config_merged.setdefault("directories", {})
|
||||||
|
|||||||
@@ -135,9 +135,7 @@ def handle_changelog(
|
|||||||
target_tag=range_arg,
|
target_tag=range_arg,
|
||||||
)
|
)
|
||||||
if cur_tag is None:
|
if cur_tag is None:
|
||||||
print(
|
print(f"[WARN] Tag {range_arg!r} not found or not a SemVer tag.")
|
||||||
f"[WARN] Tag {range_arg!r} not found or not a SemVer tag."
|
|
||||||
)
|
|
||||||
print("[INFO] Falling back to full history.")
|
print("[INFO] Falling back to full history.")
|
||||||
from_ref = None
|
from_ref = None
|
||||||
to_ref = None
|
to_ref = None
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/cli/commands/config.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -38,29 +39,18 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _find_defaults_source_dir() -> Optional[str]:
|
def _find_defaults_source_dir() -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Find the directory inside the installed pkgmgr package OR the
|
Find the directory inside the installed pkgmgr package that contains
|
||||||
project root that contains default config files.
|
the default config files.
|
||||||
|
|
||||||
Preferred locations (in dieser Reihenfolge):
|
Preferred location:
|
||||||
- <pkg_root>/config_defaults
|
|
||||||
- <pkg_root>/config
|
- <pkg_root>/config
|
||||||
- <project_root>/config_defaults
|
|
||||||
- <project_root>/config
|
|
||||||
"""
|
"""
|
||||||
import pkgmgr # local import to avoid circular deps
|
import pkgmgr # local import to avoid circular deps
|
||||||
|
|
||||||
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
||||||
project_root = pkg_root.parent
|
cand = pkg_root / "config"
|
||||||
|
if cand.is_dir():
|
||||||
candidates = [
|
return str(cand)
|
||||||
pkg_root / "config_defaults",
|
|
||||||
pkg_root / "config",
|
|
||||||
project_root / "config_defaults",
|
|
||||||
project_root / "config",
|
|
||||||
]
|
|
||||||
for cand in candidates:
|
|
||||||
if cand.is_dir():
|
|
||||||
return str(cand)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +63,7 @@ def _update_default_configs(user_config_path: str) -> None:
|
|||||||
source_dir = _find_defaults_source_dir()
|
source_dir = _find_defaults_source_dir()
|
||||||
if not source_dir:
|
if not source_dir:
|
||||||
print(
|
print(
|
||||||
"[WARN] No config_defaults or config directory found in "
|
"[WARN] No config directory found in "
|
||||||
"pkgmgr installation. Nothing to update."
|
"pkgmgr installation. Nothing to update."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -88,7 +78,6 @@ def _update_default_configs(user_config_path: str) -> None:
|
|||||||
if not (lower.endswith(".yml") or lower.endswith(".yaml")):
|
if not (lower.endswith(".yml") or lower.endswith(".yaml")):
|
||||||
continue
|
continue
|
||||||
if name == "config.yaml":
|
if name == "config.yaml":
|
||||||
# Never overwrite the user config template / live config
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
src = os.path.join(source_dir, name)
|
src = os.path.join(source_dir, name)
|
||||||
@@ -102,48 +91,28 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
"""
|
"""
|
||||||
Handle 'pkgmgr config' subcommands.
|
Handle 'pkgmgr config' subcommands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_config_path = ctx.user_config_path
|
user_config_path = ctx.user_config_path
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config show
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "show":
|
if args.subcommand == "show":
|
||||||
if args.all or (not args.identifiers):
|
if args.all or (not args.identifiers):
|
||||||
# Full merged config view
|
|
||||||
show_config([], user_config_path, full_config=True)
|
show_config([], user_config_path, full_config=True)
|
||||||
else:
|
else:
|
||||||
# Show only matching entries from user config
|
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
selected = resolve_repos(
|
selected = resolve_repos(
|
||||||
args.identifiers,
|
args.identifiers, user_config.get("repositories", [])
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
)
|
||||||
if selected:
|
if selected:
|
||||||
show_config(
|
show_config(selected, user_config_path, full_config=False)
|
||||||
selected,
|
|
||||||
user_config_path,
|
|
||||||
full_config=False,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config add
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "add":
|
if args.subcommand == "add":
|
||||||
interactive_add(ctx.config_merged, user_config_path)
|
interactive_add(ctx.config_merged, user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config edit
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "edit":
|
if args.subcommand == "edit":
|
||||||
run_command(f"nano {user_config_path}")
|
run_command(f"nano {user_config_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config init
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "init":
|
if args.subcommand == "init":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
config_init(
|
config_init(
|
||||||
@@ -154,9 +123,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config delete
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "delete":
|
if args.subcommand == "delete":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
|
|
||||||
@@ -167,10 +133,7 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
to_delete = resolve_repos(
|
to_delete = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||||
args.identifiers,
|
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
|
||||||
new_repos = [
|
new_repos = [
|
||||||
entry
|
entry
|
||||||
for entry in user_config.get("repositories", [])
|
for entry in user_config.get("repositories", [])
|
||||||
@@ -181,9 +144,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
print(f"Deleted {len(to_delete)} entries from user config.")
|
print(f"Deleted {len(to_delete)} entries from user config.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config ignore
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "ignore":
|
if args.subcommand == "ignore":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
|
|
||||||
@@ -194,17 +154,10 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
to_modify = resolve_repos(
|
to_modify = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||||
args.identifiers,
|
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in user_config["repositories"]:
|
for entry in user_config["repositories"]:
|
||||||
key = (
|
key = (entry.get("provider"), entry.get("account"), entry.get("repository"))
|
||||||
entry.get("provider"),
|
|
||||||
entry.get("account"),
|
|
||||||
entry.get("repository"),
|
|
||||||
)
|
|
||||||
for mod in to_modify:
|
for mod in to_modify:
|
||||||
mod_key = (
|
mod_key = (
|
||||||
mod.get("provider"),
|
mod.get("provider"),
|
||||||
@@ -213,28 +166,14 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
if key == mod_key:
|
if key == mod_key:
|
||||||
entry["ignore"] = args.set == "true"
|
entry["ignore"] = args.set == "true"
|
||||||
print(
|
print(f"Set ignore for {key} to {entry['ignore']}")
|
||||||
f"Set ignore for {key} to {entry['ignore']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
save_user_config(user_config, user_config_path)
|
save_user_config(user_config, user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config update
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "update":
|
if args.subcommand == "update":
|
||||||
"""
|
|
||||||
Copy default YAML configs from the installed package into the
|
|
||||||
user's ~/.config/pkgmgr directory.
|
|
||||||
|
|
||||||
This will overwrite files with the same name (except config.yaml).
|
|
||||||
"""
|
|
||||||
_update_default_configs(user_config_path)
|
_update_default_configs(user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Unknown subcommand
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
print(f"Unknown config subcommand: {args.subcommand}")
|
print(f"Unknown config subcommand: {args.subcommand}")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from pkgmgr.actions.mirror import diff_mirrors, list_mirrors, merge_mirrors, setup_mirrors
|
from pkgmgr.actions.mirror import (
|
||||||
|
diff_mirrors,
|
||||||
|
list_mirrors,
|
||||||
|
merge_mirrors,
|
||||||
|
set_mirror_visibility,
|
||||||
|
setup_mirrors,
|
||||||
|
)
|
||||||
from pkgmgr.cli.context import CLIContext
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = Dict[str, Any]
|
||||||
@@ -25,6 +31,7 @@ def handle_mirror_command(
|
|||||||
- mirror setup
|
- mirror setup
|
||||||
- mirror check
|
- mirror check
|
||||||
- mirror provision
|
- mirror provision
|
||||||
|
- mirror visibility
|
||||||
"""
|
"""
|
||||||
if not selected:
|
if not selected:
|
||||||
print("[INFO] No repositories selected for 'mirror' command.")
|
print("[INFO] No repositories selected for 'mirror' command.")
|
||||||
@@ -56,11 +63,15 @@ def handle_mirror_command(
|
|||||||
preview = getattr(args, "preview", False)
|
preview = getattr(args, "preview", False)
|
||||||
|
|
||||||
if source == target:
|
if source == target:
|
||||||
print("[ERROR] For 'mirror merge', source and target must differ (config vs file).")
|
print(
|
||||||
|
"[ERROR] For 'mirror merge', source and target must differ (config vs file)."
|
||||||
|
)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
explicit_config_path = getattr(args, "config_path", None)
|
explicit_config_path = getattr(args, "config_path", None)
|
||||||
user_config_path = explicit_config_path or getattr(ctx, "user_config_path", None)
|
user_config_path = explicit_config_path or getattr(
|
||||||
|
ctx, "user_config_path", None
|
||||||
|
)
|
||||||
|
|
||||||
merge_mirrors(
|
merge_mirrors(
|
||||||
selected_repos=selected,
|
selected_repos=selected,
|
||||||
@@ -83,6 +94,7 @@ def handle_mirror_command(
|
|||||||
local=True,
|
local=True,
|
||||||
remote=False,
|
remote=False,
|
||||||
ensure_remote=False,
|
ensure_remote=False,
|
||||||
|
ensure_visibility=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -96,11 +108,14 @@ def handle_mirror_command(
|
|||||||
local=False,
|
local=False,
|
||||||
remote=True,
|
remote=True,
|
||||||
ensure_remote=False,
|
ensure_remote=False,
|
||||||
|
ensure_visibility=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if subcommand == "provision":
|
if subcommand == "provision":
|
||||||
preview = getattr(args, "preview", False)
|
preview = getattr(args, "preview", False)
|
||||||
|
public = bool(getattr(args, "public", False))
|
||||||
|
|
||||||
setup_mirrors(
|
setup_mirrors(
|
||||||
selected_repos=selected,
|
selected_repos=selected,
|
||||||
repositories_base_dir=ctx.repositories_base_dir,
|
repositories_base_dir=ctx.repositories_base_dir,
|
||||||
@@ -109,6 +124,23 @@ def handle_mirror_command(
|
|||||||
local=False,
|
local=False,
|
||||||
remote=True,
|
remote=True,
|
||||||
ensure_remote=True,
|
ensure_remote=True,
|
||||||
|
ensure_visibility="public" if public else None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if subcommand == "visibility":
|
||||||
|
preview = getattr(args, "preview", False)
|
||||||
|
visibility = getattr(args, "visibility", None)
|
||||||
|
if visibility not in ("private", "public"):
|
||||||
|
print("[ERROR] mirror visibility expects 'private' or 'public'.")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
set_mirror_visibility(
|
||||||
|
selected_repos=selected,
|
||||||
|
repositories_base_dir=ctx.repositories_base_dir,
|
||||||
|
all_repos=ctx.all_repositories,
|
||||||
|
visibility=visibility,
|
||||||
|
preview=preview,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ def handle_publish(args, ctx: CLIContext, selected: List[Repository]) -> None:
|
|||||||
|
|
||||||
for repo in selected:
|
for repo in selected:
|
||||||
identifier = get_repo_identifier(repo, ctx.all_repositories)
|
identifier = get_repo_identifier(repo, ctx.all_repositories)
|
||||||
repo_dir = repo.get("directory") or get_repo_dir(ctx.repositories_base_dir, repo)
|
repo_dir = repo.get("directory") or get_repo_dir(
|
||||||
|
ctx.repositories_base_dir, repo
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.isdir(repo_dir):
|
if not os.path.isdir(repo_dir):
|
||||||
print(f"[WARN] Skipping {identifier}: directory missing.")
|
print(f"[WARN] Skipping {identifier}: directory missing.")
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ def handle_release(
|
|||||||
identifier = get_repo_identifier(repo, ctx.all_repositories)
|
identifier = get_repo_identifier(repo, ctx.all_repositories)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
repo_dir = repo.get("directory") or get_repo_dir(ctx.repositories_base_dir, repo)
|
repo_dir = repo.get("directory") or get_repo_dir(
|
||||||
|
ctx.repositories_base_dir, repo
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}")
|
print(
|
||||||
|
f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(repo_dir):
|
if not os.path.isdir(repo_dir):
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ def _resolve_repository_directory(repository: Repository, ctx: CLIContext) -> st
|
|||||||
if repo_dir:
|
if repo_dir:
|
||||||
return repo_dir
|
return repo_dir
|
||||||
|
|
||||||
base_dir = (
|
base_dir = getattr(ctx, "repositories_base_dir", None) or getattr(
|
||||||
getattr(ctx, "repositories_base_dir", None)
|
ctx, "repositories_dir", None
|
||||||
or getattr(ctx, "repositories_dir", None)
|
|
||||||
)
|
)
|
||||||
if not base_dir:
|
if not base_dir:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -1,115 +1,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from typing import Any, Dict, List
|
||||||
import os
|
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
from pkgmgr .cli .context import CLIContext
|
|
||||||
from pkgmgr .core .command .run import run_command
|
|
||||||
from pkgmgr .core .repository .identifier import get_repo_identifier
|
|
||||||
from pkgmgr .core .repository .dir import get_repo_dir
|
|
||||||
|
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
from pkgmgr.cli.tools import open_vscode_workspace
|
||||||
|
from pkgmgr.cli.tools.paths import resolve_repository_path
|
||||||
|
from pkgmgr.core.command.run import run_command
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
|
|
||||||
"""
|
|
||||||
Resolve the filesystem path for a repository.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
|
|
||||||
2. Fallback to get_repo_dir(...) using the repositories base directory
|
|
||||||
from the CLI context.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 1) Explicit path-like keys on the repository object
|
|
||||||
for key in ("directory", "path", "workspace", "workspace_dir"):
|
|
||||||
value = repository.get(key)
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
|
|
||||||
# 2) Fallback: compute from base dir + repository metadata
|
|
||||||
base_dir = (
|
|
||||||
getattr(ctx, "repositories_base_dir", None)
|
|
||||||
or getattr(ctx, "repositories_dir", None)
|
|
||||||
)
|
|
||||||
if not base_dir:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Cannot resolve repositories base directory from context; "
|
|
||||||
"expected ctx.repositories_base_dir or ctx.repositories_dir."
|
|
||||||
)
|
|
||||||
|
|
||||||
return get_repo_dir(base_dir, repository)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_tools_command(
|
def handle_tools_command(
|
||||||
args,
|
args,
|
||||||
ctx: CLIContext,
|
ctx: CLIContext,
|
||||||
selected: List[Repository],
|
selected: List[Repository],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# nautilus "explore" command
|
# nautilus "explore" command
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if args.command == "explore":
|
if args.command == "explore":
|
||||||
for repository in selected:
|
for repository in selected:
|
||||||
repo_path = _resolve_repository_path(repository, ctx)
|
repo_path = resolve_repository_path(repository, ctx)
|
||||||
run_command(
|
run_command(f'nautilus "{repo_path}" & disown')
|
||||||
f'nautilus "{repo_path}" & disown'
|
return
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GNOME terminal command
|
# GNOME terminal command
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if args.command == "terminal":
|
if args.command == "terminal":
|
||||||
for repository in selected:
|
for repository in selected:
|
||||||
repo_path = _resolve_repository_path(repository, ctx)
|
repo_path = resolve_repository_path(repository, ctx)
|
||||||
run_command(
|
run_command(f'gnome-terminal --tab --working-directory="{repo_path}"')
|
||||||
f'gnome-terminal --tab --working-directory="{repo_path}"'
|
return
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# VS Code workspace command
|
# VS Code workspace command
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if args.command == "code":
|
if args.command == "code":
|
||||||
if not selected:
|
open_vscode_workspace(ctx, selected)
|
||||||
print("No repositories selected.")
|
|
||||||
return
|
|
||||||
|
|
||||||
identifiers = [
|
|
||||||
get_repo_identifier(repo, ctx.all_repositories)
|
|
||||||
for repo in selected
|
|
||||||
]
|
|
||||||
sorted_identifiers = sorted(identifiers)
|
|
||||||
workspace_name = "_".join(sorted_identifiers) + ".code-workspace"
|
|
||||||
|
|
||||||
directories_cfg = ctx.config_merged.get("directories") or {}
|
|
||||||
workspaces_dir = os.path.expanduser(
|
|
||||||
directories_cfg.get("workspaces", "~/Workspaces")
|
|
||||||
)
|
|
||||||
os.makedirs(workspaces_dir, exist_ok=True)
|
|
||||||
workspace_file = os.path.join(workspaces_dir, workspace_name)
|
|
||||||
|
|
||||||
folders = [
|
|
||||||
{"path": _resolve_repository_path(repository, ctx)}
|
|
||||||
for repository in selected
|
|
||||||
]
|
|
||||||
|
|
||||||
workspace_data = {
|
|
||||||
"folders": folders,
|
|
||||||
"settings": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if not os.path.exists(workspace_file):
|
|
||||||
with open(workspace_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(workspace_data, f, indent=4)
|
|
||||||
print(f"Created workspace file: {workspace_file}")
|
|
||||||
else:
|
|
||||||
print(f"Using existing workspace file: {workspace_file}")
|
|
||||||
|
|
||||||
run_command(f'code "{workspace_file}"')
|
|
||||||
return
|
return
|
||||||
@@ -38,9 +38,9 @@ def _print_pkgmgr_self_version() -> None:
|
|||||||
|
|
||||||
# Common distribution/module naming variants.
|
# Common distribution/module naming variants.
|
||||||
python_candidates = [
|
python_candidates = [
|
||||||
"package-manager", # PyPI dist name in your project
|
"package-manager", # PyPI dist name in your project
|
||||||
"package_manager", # module-ish variant
|
"package_manager", # module-ish variant
|
||||||
"pkgmgr", # console/alias-ish
|
"pkgmgr", # console/alias-ish
|
||||||
]
|
]
|
||||||
nix_candidates = [
|
nix_candidates = [
|
||||||
"pkgmgr",
|
"pkgmgr",
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ def add_branch_subparsers(
|
|||||||
"name",
|
"name",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help=(
|
help=(
|
||||||
"Name of the new branch (optional; will be asked interactively "
|
"Name of the new branch (optional; will be asked interactively if omitted)"
|
||||||
"if omitted)"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
branch_open.add_argument(
|
branch_open.add_argument(
|
||||||
@@ -54,8 +53,7 @@ def add_branch_subparsers(
|
|||||||
"name",
|
"name",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help=(
|
help=(
|
||||||
"Name of the branch to close (optional; current branch is used "
|
"Name of the branch to close (optional; current branch is used if omitted)"
|
||||||
"if omitted)"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
branch_close.add_argument(
|
branch_close.add_argument(
|
||||||
@@ -84,8 +82,7 @@ def add_branch_subparsers(
|
|||||||
"name",
|
"name",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help=(
|
help=(
|
||||||
"Name of the branch to drop (optional; current branch is used "
|
"Name of the branch to drop (optional; current branch is used if omitted)"
|
||||||
"if omitted)"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
branch_drop.add_argument(
|
branch_drop.add_argument(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# src/pkgmgr/cli/parser/mirror_cmd.py
|
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ from .common import add_identifier_arguments
|
|||||||
def add_mirror_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
def add_mirror_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
||||||
mirror_parser = subparsers.add_parser(
|
mirror_parser = subparsers.add_parser(
|
||||||
"mirror",
|
"mirror",
|
||||||
help="Mirror-related utilities (list, diff, merge, setup, check, provision)",
|
help="Mirror-related utilities (list, diff, merge, setup, check, provision, visibility)",
|
||||||
)
|
)
|
||||||
mirror_subparsers = mirror_parser.add_subparsers(
|
mirror_subparsers = mirror_parser.add_subparsers(
|
||||||
dest="subcommand",
|
dest="subcommand",
|
||||||
@@ -20,7 +19,9 @@ def add_mirror_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
mirror_list = mirror_subparsers.add_parser("list", help="List configured mirrors for repositories")
|
mirror_list = mirror_subparsers.add_parser(
|
||||||
|
"list", help="List configured mirrors for repositories"
|
||||||
|
)
|
||||||
add_identifier_arguments(mirror_list)
|
add_identifier_arguments(mirror_list)
|
||||||
mirror_list.add_argument(
|
mirror_list.add_argument(
|
||||||
"--source",
|
"--source",
|
||||||
@@ -29,15 +30,21 @@ def add_mirror_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
|||||||
help="Which mirror source to show.",
|
help="Which mirror source to show.",
|
||||||
)
|
)
|
||||||
|
|
||||||
mirror_diff = mirror_subparsers.add_parser("diff", help="Show differences between config mirrors and MIRRORS file")
|
mirror_diff = mirror_subparsers.add_parser(
|
||||||
|
"diff", help="Show differences between config mirrors and MIRRORS file"
|
||||||
|
)
|
||||||
add_identifier_arguments(mirror_diff)
|
add_identifier_arguments(mirror_diff)
|
||||||
|
|
||||||
mirror_merge = mirror_subparsers.add_parser(
|
mirror_merge = mirror_subparsers.add_parser(
|
||||||
"merge",
|
"merge",
|
||||||
help="Merge mirrors between config and MIRRORS file (example: pkgmgr mirror merge config file --all)",
|
help="Merge mirrors between config and MIRRORS file (example: pkgmgr mirror merge config file --all)",
|
||||||
)
|
)
|
||||||
mirror_merge.add_argument("source", choices=["config", "file"], help="Source of mirrors.")
|
mirror_merge.add_argument(
|
||||||
mirror_merge.add_argument("target", choices=["config", "file"], help="Target of mirrors.")
|
"source", choices=["config", "file"], help="Source of mirrors."
|
||||||
|
)
|
||||||
|
mirror_merge.add_argument(
|
||||||
|
"target", choices=["config", "file"], help="Target of mirrors."
|
||||||
|
)
|
||||||
add_identifier_arguments(mirror_merge)
|
add_identifier_arguments(mirror_merge)
|
||||||
mirror_merge.add_argument(
|
mirror_merge.add_argument(
|
||||||
"--config-path",
|
"--config-path",
|
||||||
@@ -60,4 +67,20 @@ def add_mirror_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
|||||||
"provision",
|
"provision",
|
||||||
help="Provision remote repositories via provider APIs (create missing repos).",
|
help="Provision remote repositories via provider APIs (create missing repos).",
|
||||||
)
|
)
|
||||||
|
mirror_provision.add_argument(
|
||||||
|
"--public",
|
||||||
|
action="store_true",
|
||||||
|
help="After ensuring repos exist, enforce public visibility on the remote provider.",
|
||||||
|
)
|
||||||
add_identifier_arguments(mirror_provision)
|
add_identifier_arguments(mirror_provision)
|
||||||
|
|
||||||
|
mirror_visibility = mirror_subparsers.add_parser(
|
||||||
|
"visibility",
|
||||||
|
help="Set visibility (public/private) for all remote git mirrors via provider APIs.",
|
||||||
|
)
|
||||||
|
mirror_visibility.add_argument(
|
||||||
|
"visibility",
|
||||||
|
choices=["private", "public"],
|
||||||
|
help="Target visibility for all git mirrors.",
|
||||||
|
)
|
||||||
|
add_identifier_arguments(mirror_visibility)
|
||||||
@@ -48,9 +48,6 @@ def add_navigation_subparsers(
|
|||||||
"--command",
|
"--command",
|
||||||
nargs=argparse.REMAINDER,
|
nargs=argparse.REMAINDER,
|
||||||
dest="shell_command",
|
dest="shell_command",
|
||||||
help=(
|
help=("The shell command (and its arguments) to execute in each repository"),
|
||||||
"The shell command (and its arguments) to execute in each "
|
|
||||||
"repository"
|
|
||||||
),
|
|
||||||
default=[],
|
default=[],
|
||||||
)
|
)
|
||||||
+3
-13
@@ -53,10 +53,7 @@ def _add_proxy_identifier_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"identifiers",
|
"identifiers",
|
||||||
nargs="*",
|
nargs="*",
|
||||||
help=(
|
help=("Identifier(s) for repositories. Default: Repository of current folder."),
|
||||||
"Identifier(s) for repositories. "
|
|
||||||
"Default: Repository of current folder."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--all",
|
"--all",
|
||||||
@@ -118,12 +115,7 @@ def _proxy_has_explicit_selection(args: argparse.Namespace) -> bool:
|
|||||||
string_filter = getattr(args, "string", "") or ""
|
string_filter = getattr(args, "string", "") or ""
|
||||||
|
|
||||||
# Proxy commands currently do not support --tag, so it is not checked here.
|
# Proxy commands currently do not support --tag, so it is not checked here.
|
||||||
return bool(
|
return bool(use_all or identifiers or categories or string_filter)
|
||||||
use_all
|
|
||||||
or identifiers
|
|
||||||
or categories
|
|
||||||
or string_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _select_repo_for_current_directory(
|
def _select_repo_for_current_directory(
|
||||||
@@ -204,9 +196,7 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
|||||||
If the top-level command is one of the proxy subcommands
|
If the top-level command is one of the proxy subcommands
|
||||||
(git / docker / docker compose), handle it here and return True.
|
(git / docker / docker compose), handle it here and return True.
|
||||||
"""
|
"""
|
||||||
all_proxy_subcommands = {
|
all_proxy_subcommands = {sub for subs in PROXY_COMMANDS.values() for sub in subs}
|
||||||
sub for subs in PROXY_COMMANDS.values() for sub in subs
|
|
||||||
}
|
|
||||||
|
|
||||||
if args.command not in all_proxy_subcommands:
|
if args.command not in all_proxy_subcommands:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .vscode import open_vscode_workspace
|
||||||
|
|
||||||
|
__all__ = ["open_vscode_workspace"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
|
||||||
|
"""
|
||||||
|
Resolve the filesystem path for a repository.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
|
||||||
|
2. Fallback to get_repo_dir(...) using the repositories base directory
|
||||||
|
from the CLI context.
|
||||||
|
"""
|
||||||
|
for key in ("directory", "path", "workspace", "workspace_dir"):
|
||||||
|
value = repository.get(key)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
base_dir = getattr(ctx, "repositories_base_dir", None) or getattr(
|
||||||
|
ctx, "repositories_dir", None
|
||||||
|
)
|
||||||
|
if not base_dir:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot resolve repositories base directory from context; "
|
||||||
|
"expected ctx.repositories_base_dir or ctx.repositories_dir."
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_repo_dir(base_dir, repository)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
from pkgmgr.cli.tools.paths import resolve_repository_path
|
||||||
|
from pkgmgr.core.command.run import run_command
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
|
||||||
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_vscode_cli_available() -> None:
|
||||||
|
"""
|
||||||
|
Ensure that the VS Code CLI ('code') is available in PATH.
|
||||||
|
"""
|
||||||
|
if shutil.which("code") is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"VS Code CLI ('code') not found in PATH.\n\n"
|
||||||
|
"Hint:\n"
|
||||||
|
" Install Visual Studio Code and ensure the 'code' command is available.\n"
|
||||||
|
" VS Code → Command Palette → 'Shell Command: Install code command in PATH'\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_identifiers_are_filename_safe(identifiers: List[str]) -> None:
|
||||||
|
"""
|
||||||
|
Ensure identifiers can be used in a filename.
|
||||||
|
|
||||||
|
If an identifier contains '/', it likely means the repository has not yet
|
||||||
|
been explicitly identified (no short identifier configured).
|
||||||
|
"""
|
||||||
|
invalid = [i for i in identifiers if "/" in i or os.sep in i]
|
||||||
|
if invalid:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot create VS Code workspace.\n\n"
|
||||||
|
"The following repositories are not yet identified "
|
||||||
|
"(identifier contains '/'): \n"
|
||||||
|
+ "\n".join(f" - {i}" for i in invalid)
|
||||||
|
+ "\n\n"
|
||||||
|
"Hint:\n"
|
||||||
|
" The repository has no short identifier yet.\n"
|
||||||
|
" Add an explicit identifier in your configuration before using `pkgmgr tools code`.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workspaces_dir(ctx: CLIContext) -> str:
|
||||||
|
directories_cfg = ctx.config_merged.get("directories") or {}
|
||||||
|
return os.path.expanduser(directories_cfg.get("workspaces", "~/Workspaces"))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_workspace_filename(identifiers: List[str]) -> str:
|
||||||
|
sorted_identifiers = sorted(identifiers)
|
||||||
|
return "_".join(sorted_identifiers) + ".code-workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_workspace_data(
|
||||||
|
selected: List[Repository], ctx: CLIContext
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
folders = [{"path": resolve_repository_path(repo, ctx)} for repo in selected]
|
||||||
|
return {
|
||||||
|
"folders": folders,
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def open_vscode_workspace(ctx: CLIContext, selected: List[Repository]) -> None:
|
||||||
|
"""
|
||||||
|
Create (if missing) and open a VS Code workspace for the selected repositories.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Fail with a clear error if VS Code CLI is missing.
|
||||||
|
- Fail with a clear error if any repository identifier contains '/', because that
|
||||||
|
indicates the repo has not been explicitly identified (no short identifier).
|
||||||
|
- Do NOT auto-sanitize identifiers and do NOT create subfolders under workspaces.
|
||||||
|
"""
|
||||||
|
if not selected:
|
||||||
|
print("No repositories selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_ensure_vscode_cli_available()
|
||||||
|
|
||||||
|
identifiers = [get_repo_identifier(repo, ctx.all_repositories) for repo in selected]
|
||||||
|
_ensure_identifiers_are_filename_safe(identifiers)
|
||||||
|
|
||||||
|
workspaces_dir = _resolve_workspaces_dir(ctx)
|
||||||
|
os.makedirs(workspaces_dir, exist_ok=True)
|
||||||
|
|
||||||
|
workspace_name = _build_workspace_filename(identifiers)
|
||||||
|
workspace_file = os.path.join(workspaces_dir, workspace_name)
|
||||||
|
|
||||||
|
workspace_data = _build_workspace_data(selected, ctx)
|
||||||
|
|
||||||
|
if not os.path.exists(workspace_file):
|
||||||
|
with open(workspace_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(workspace_data, f, indent=4)
|
||||||
|
print(f"Created workspace file: {workspace_file}")
|
||||||
|
else:
|
||||||
|
print(f"Using existing workspace file: {workspace_file}")
|
||||||
|
|
||||||
|
run_command(f'code "{workspace_file}"')
|
||||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user