Compare commits

..

8 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
c5c84704db Release version 1.8.5
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 22:15:48 +01:00
Kevin Veen-Birkenbach
c46df92953 Release version 1.9.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 22:10:31 +01:00
Kevin Veen-Birkenbach
997c265cfb refactor(git): introduce GitRunError hierarchy, surface non-repo errors, and improve verification queries
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Replace legacy GitError usage with a clearer exception hierarchy:

  * GitBaseError as the common root for all git-related failures
  * GitRunError for subprocess execution failures
  * GitQueryError for read-only query failures
  * GitCommandError for state-changing command failures
  * GitNotRepositoryError to explicitly signal “not a git repository” situations
* Update git runner to detect “not a git repository” stderr and raise GitNotRepositoryError with rich context (cwd, command, stderr)
* Refactor repository verification to use dedicated query helpers instead of ad-hoc subprocess calls:

  * get_remote_head_commit (ls-remote) for pull mode
  * get_head_commit for local mode
  * get_latest_signing_key (%GK) for signature verification
* Add strict vs best-effort behavior in verify_repository:

  * Best-effort collection for reporting (does not block when no verification config exists)
  * Strict retrieval and explicit error messages when verification is configured
  * Clear failure cases when commit/signing key cannot be determined
* Add new unit tests covering:

  * get_latest_signing_key output stripping and error wrapping
  * get_remote_head_commit parsing, empty output, and error wrapping
  * verify_repository success/failure scenarios and “do not swallow GitNotRepositoryError”
* Adjust imports and exception handling across actions/commands/queries to align with GitRunError-based handling while keeping GitNotRepositoryError uncaught for debugging clarity

https://chatgpt.com/share/6943173c-508c-800f-8879-af75d131c79b
2025-12-17 21:48:03 +01:00
Kevin Veen-Birkenbach
955028288f Release version 1.8.4
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 11:20:16 +01:00
Kevin Veen-Birkenbach
866572e252 ci(docker): fix repo mount path for pkgmgr as base layer of Infinito.Nexus
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Standardize Docker/CI/test environments to mount pkgmgr at /opt/src/pkgmgr.
This makes the layering explicit: pkgmgr is the lower-level foundation used by
Infinito.Nexus.

Infra-only change (Docker, CI, shell scripts). No runtime or Nix semantics changed.

https://chatgpt.com/share/69427fe7-e288-800f-90a4-c1c3c11a8484
2025-12-17 11:03:02 +01:00
Kevin Veen-Birkenbach
b0a733369e Optimized output for debugging
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 10:51:56 +01:00
Kevin Veen-Birkenbach
c5843ccd30 Release version 1.8.3
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-16 19:49:51 +01:00
Kevin Veen-Birkenbach
3cb7852cb4 feat(mirrors): support URL-only MIRRORS entries and keep git config clean
- Allow MIRRORS to contain plain URLs (one per line) in addition to legacy "NAME URL"
- Treat strings as single URLs to avoid iterable pitfalls
- Write PyPI URLs as metadata-only entries (never added to git config)
- Keep MIRRORS as the single source of truth for mirror setup
- Update integration test to assert URL-only MIRRORS output

https://chatgpt.com/share/6941a9aa-b8b4-800f-963d-2486b34856b1
2025-12-16 19:49:09 +01:00
77 changed files with 570 additions and 259 deletions

View File

@@ -31,15 +31,15 @@ jobs:
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 \ -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 +50,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
' '

View File

@@ -31,8 +31,8 @@ jobs:
set -euo pipefail set -euo pipefail
docker run --rm \ docker run --rm \
-v "$PWD":/src \ -v "$PWD":/opt/src/pkgmgr \
-w /src \ -w /opt/src/pkgmgr \
"pkgmgr-${{ matrix.distro }}-virgin" \ "pkgmgr-${{ matrix.distro }}-virgin" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
@@ -42,7 +42,7 @@ 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
@@ -51,7 +51,7 @@ jobs:
sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc " sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
set -euo pipefail set -euo pipefail
cd /src cd /opt/src/pkgmgr
make setup-venv make setup-venv
. \"\$HOME/.venvs/pkgmgr/bin/activate\" . \"\$HOME/.venvs/pkgmgr/bin/activate\"
@@ -59,6 +59,6 @@ jobs:
pkgmgr version pkgmgr pkgmgr version pkgmgr
export NIX_REMOTE=local export NIX_REMOTE=local
nix run /src#pkgmgr -- version pkgmgr nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
" "
' '

View File

@@ -1,3 +1,25 @@
## [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 pkgmgrs 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 ## [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. * * ***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.

View File

@@ -50,6 +50,6 @@ 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"]

View File

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

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=1.8.2 pkgver=1.8.5
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')

View File

@@ -1,3 +1,29 @@
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 pkgmgrs 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 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. * * ***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.

View File

@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 1.8.2 Version: 1.8.5
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,20 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* 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 pkgmgrs 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 * 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. - * ***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.

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "1.8.2" version = "1.8.5"
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"

View File

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

View File

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

View File

@@ -6,12 +6,12 @@ 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 \ --workdir /opt/src/pkgmgr \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
@@ -40,14 +40,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 +55,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"
' '

View File

@@ -9,18 +9,18 @@ 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 \
"${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 +38,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

View File

@@ -17,8 +17,8 @@ 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 \
"${IMAGE}" \ "${IMAGE}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail

View File

@@ -6,19 +6,19 @@ 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}" \
"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";
' '

View File

@@ -6,19 +6,19 @@ 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}" \
"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";
' '

View File

@@ -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:
@@ -55,7 +55,7 @@ def close_branch(
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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ 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
from pkgmgr.core.git.commands import add, commit, push, tag_annotated from pkgmgr.core.git.commands import add, commit, push, 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}")
@@ -158,7 +158,7 @@ def _release_impl(
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(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).")
except GitError as exc: 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.")

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .errors import GitError from .errors import GitRunError
from .run import run from .run import run
""" """
@@ -12,6 +12,6 @@ details of subprocess handling.
""" """
__all__ = [ __all__ = [
"GitError", "GitRunError",
"run", "run",
] ]

View File

@@ -16,7 +16,7 @@ from .fetch import GitFetchError, fetch
from .init import GitInitError, init from .init import GitInitError, init
from .merge_no_ff import GitMergeError, merge_no_ff from .merge_no_ff import GitMergeError, merge_no_ff
from .pull import GitPullError, pull from .pull import GitPullError, pull
from .pull_args import GitPullArgsError, pull_args # <-- add from .pull_args import GitPullArgsError, pull_args
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
from .push import GitPushError, push from .push import GitPushError, push
from .push_upstream import GitPushUpstreamError, push_upstream from .push_upstream import GitPushUpstreamError, push_upstream
@@ -30,7 +30,7 @@ __all__ = [
"fetch", "fetch",
"checkout", "checkout",
"pull", "pull",
"pull_args", # <-- add "pull_args",
"pull_ff_only", "pull_ff_only",
"merge_no_ff", "merge_no_ff",
"push", "push",
@@ -52,7 +52,7 @@ __all__ = [
"GitFetchError", "GitFetchError",
"GitCheckoutError", "GitCheckoutError",
"GitPullError", "GitPullError",
"GitPullArgsError", # <-- add "GitPullArgsError",
"GitPullFfOnlyError", "GitPullFfOnlyError",
"GitMergeError", "GitMergeError",
"GitPushError", "GitPushError",

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Iterable, List, Sequence, Union from typing import Iterable, List, Sequence, Union
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -37,7 +37,7 @@ def add(
try: try:
run(["add", *normalized], cwd=cwd, preview=preview) run(["add", *normalized], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitAddError( raise GitAddError(
f"Failed to add paths to staging area: {normalized!r}.", f"Failed to add paths to staging area: {normalized!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,7 +1,6 @@
# src/pkgmgr/core/git/commands/add_all.py
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitCommandError, GitRunError
from ..run import run from ..run import run
@@ -18,5 +17,5 @@ def add_all(*, cwd: str = ".", preview: bool = False) -> None:
""" """
try: try:
run(["add", "-A"], cwd=cwd, preview=preview) run(["add", "-A"], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitAddAllError("Failed to stage all changes with `git add -A`.", cwd=cwd) from exc raise GitAddAllError("Failed to stage all changes with `git add -A`.", cwd=cwd) from exc

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -27,7 +27,7 @@ def add_remote(
cwd=cwd, cwd=cwd,
preview=preview, preview=preview,
) )
except GitError as exc: except GitRunError as exc:
raise GitAddRemoteError( raise GitAddRemoteError(
f"Failed to add remote {name!r} with URL {url!r}.", f"Failed to add remote {name!r} with URL {url!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -27,7 +27,7 @@ def add_remote_push_url(
cwd=cwd, cwd=cwd,
preview=preview, preview=preview,
) )
except GitError as exc: except GitRunError as exc:
raise GitAddRemotePushUrlError( raise GitAddRemotePushUrlError(
f"Failed to add push url {url!r} to remote {remote!r}.", f"Failed to add push url {url!r} to remote {remote!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,7 +1,6 @@
# src/pkgmgr/core/git/commands/branch_move.py
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -18,5 +17,5 @@ def branch_move(branch: str, *, cwd: str = ".", preview: bool = False) -> None:
""" """
try: try:
run(["branch", "-M", branch], cwd=cwd, preview=preview) run(["branch", "-M", branch], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitBranchMoveError(f"Failed to move/rename current branch to {branch!r}.", cwd=cwd) from exc raise GitBranchMoveError(f"Failed to move/rename current branch to {branch!r}.", cwd=cwd) from exc

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -11,7 +11,7 @@ class GitCheckoutError(GitCommandError):
def checkout(branch: str, cwd: str = ".") -> None: def checkout(branch: str, cwd: str = ".") -> None:
try: try:
run(["checkout", branch], cwd=cwd) run(["checkout", branch], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitCheckoutError( raise GitCheckoutError(
f"Failed to checkout branch {branch!r}.", f"Failed to checkout branch {branch!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List from typing import List
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -25,7 +25,7 @@ def clone(
""" """
try: try:
run(["clone", *args], cwd=cwd, preview=preview) run(["clone", *args], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitCloneError( raise GitCloneError(
f"Git clone failed with args={args!r}.", f"Git clone failed with args={args!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -30,7 +30,7 @@ def commit(
try: try:
run(args, cwd=cwd, preview=preview) run(args, cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitCommitError( raise GitCommitError(
"Failed to create commit.", "Failed to create commit.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -16,7 +16,7 @@ def create_branch(branch: str, base: str, cwd: str = ".") -> None:
""" """
try: try:
run(["checkout", "-b", branch, base], cwd=cwd) run(["checkout", "-b", branch, base], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitCreateBranchError( raise GitCreateBranchError(
f"Failed to create branch {branch!r} from base {base!r}.", f"Failed to create branch {branch!r} from base {base!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -12,7 +12,7 @@ def delete_local_branch(branch: str, cwd: str = ".", force: bool = False) -> Non
flag = "-D" if force else "-d" flag = "-D" if force else "-d"
try: try:
run(["branch", flag, branch], cwd=cwd) run(["branch", flag, branch], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitDeleteLocalBranchError( raise GitDeleteLocalBranchError(
f"Failed to delete local branch {branch!r} (flag {flag}).", f"Failed to delete local branch {branch!r} (flag {flag}).",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -11,7 +11,7 @@ class GitDeleteRemoteBranchError(GitCommandError):
def delete_remote_branch(remote: str, branch: str, cwd: str = ".") -> None: def delete_remote_branch(remote: str, branch: str, cwd: str = ".") -> None:
try: try:
run(["push", remote, "--delete", branch], cwd=cwd) run(["push", remote, "--delete", branch], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitDeleteRemoteBranchError( raise GitDeleteRemoteBranchError(
f"Failed to delete remote branch {branch!r} on {remote!r}.", f"Failed to delete remote branch {branch!r} on {remote!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -33,7 +33,7 @@ def fetch(
try: try:
run(args, cwd=cwd, preview=preview) run(args, cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitFetchError( raise GitFetchError(
f"Failed to fetch from remote {remote!r}.", f"Failed to fetch from remote {remote!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,7 +1,6 @@
# src/pkgmgr/core/git/commands/init.py
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -18,5 +17,5 @@ def init(*, cwd: str = ".", preview: bool = False) -> None:
""" """
try: try:
run(["init"], cwd=cwd, preview=preview) run(["init"], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitInitError("Failed to initialize git repository.", cwd=cwd) from exc raise GitInitError("Failed to initialize git repository.", cwd=cwd) from exc

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -11,7 +11,7 @@ class GitMergeError(GitCommandError):
def merge_no_ff(branch: str, cwd: str = ".") -> None: def merge_no_ff(branch: str, cwd: str = ".") -> None:
try: try:
run(["merge", "--no-ff", branch], cwd=cwd) run(["merge", "--no-ff", branch], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitMergeError( raise GitMergeError(
f"Failed to merge branch {branch!r} with --no-ff.", f"Failed to merge branch {branch!r} with --no-ff.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -11,7 +11,7 @@ class GitPullError(GitCommandError):
def pull(remote: str, branch: str, cwd: str = ".") -> None: def pull(remote: str, branch: str, cwd: str = ".") -> None:
try: try:
run(["pull", remote, branch], cwd=cwd) run(["pull", remote, branch], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitPullError( raise GitPullError(
f"Failed to pull {remote!r}/{branch!r}.", f"Failed to pull {remote!r}/{branch!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List from typing import List
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -28,7 +28,7 @@ def pull_args(
extra = args or [] extra = args or []
try: try:
run(["pull", *extra], cwd=cwd, preview=preview) run(["pull", *extra], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitPullArgsError( raise GitPullArgsError(
f"Failed to run `git pull` with args={extra!r}.", f"Failed to run `git pull` with args={extra!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -17,7 +17,7 @@ def pull_ff_only(*, cwd: str = ".", preview: bool = False) -> None:
""" """
try: try:
run(["pull", "--ff-only"], cwd=cwd, preview=preview) run(["pull", "--ff-only"], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitPullFfOnlyError( raise GitPullFfOnlyError(
"Failed to pull with --ff-only.", "Failed to pull with --ff-only.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -28,7 +28,7 @@ def push(
try: try:
run(args, cwd=cwd, preview=preview) run(args, cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitPushError( raise GitPushError(
f"Failed to push ref {ref!r} to remote {remote!r}.", f"Failed to push ref {ref!r} to remote {remote!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,7 +1,6 @@
# src/pkgmgr/core/git/commands/push_upstream.py
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -24,7 +23,7 @@ def push_upstream(
""" """
try: try:
run(["push", "-u", remote, branch], cwd=cwd, preview=preview) run(["push", "-u", remote, branch], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitPushUpstreamError( raise GitPushUpstreamError(
f"Failed to push branch {branch!r} to {remote!r} with upstream tracking.", f"Failed to push branch {branch!r} to {remote!r} with upstream tracking.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -35,7 +35,7 @@ def set_remote_url(
cwd=cwd, cwd=cwd,
preview=preview, preview=preview,
) )
except GitError as exc: except GitRunError as exc:
mode = "push" if push else "fetch" mode = "push" if push else "fetch"
raise GitSetRemoteUrlError( raise GitSetRemoteUrlError(
f"Failed to set {mode} url for remote {remote!r} to {url!r}.", f"Failed to set {mode} url for remote {remote!r} to {url!r}.",

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -23,7 +23,7 @@ def tag_annotated(
""" """
try: try:
run(["tag", "-a", tag, "-m", message], cwd=cwd, preview=preview) run(["tag", "-a", tag, "-m", message], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitTagAnnotatedError( raise GitTagAnnotatedError(
f"Failed to create annotated tag {tag!r}.", f"Failed to create annotated tag {tag!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError, GitCommandError from ..errors import GitRunError, GitCommandError
from ..run import run from ..run import run
@@ -24,7 +24,7 @@ def tag_force_annotated(
""" """
try: try:
run(["tag", "-f", "-a", name, target, "-m", message], cwd=cwd, preview=preview) run(["tag", "-f", "-a", name, target, "-m", message], cwd=cwd, preview=preview)
except GitError as exc: except GitRunError as exc:
raise GitTagForceAnnotatedError( raise GitTagForceAnnotatedError(
f"Failed to force annotated tag {name!r} at {target!r}.", f"Failed to force annotated tag {name!r} at {target!r}.",
cwd=cwd, cwd=cwd,

View File

@@ -1,11 +1,19 @@
from __future__ import annotations from __future__ import annotations
class GitError(RuntimeError): class GitBaseError(RuntimeError):
"""Base error raised for Git related failures.""" """Base error raised for Git related failures."""
class GitRunError(GitBaseError):
"""Base error raised for Git related failures."""
class GitCommandError(GitError): class GitNotRepositoryError(GitBaseError):
"""Raised when the current working directory is not a git repository."""
class GitQueryError(GitRunError):
"""Base class for read-only git query failures."""
class GitCommandError(GitRunError):
""" """
Base class for state-changing git command failures. Base class for state-changing git command failures.
@@ -13,4 +21,5 @@ class GitCommandError(GitError):
""" """
def __init__(self, message: str, *, cwd: str = ".") -> None: def __init__(self, message: str, *, cwd: str = ".") -> None:
super().__init__(message) super().__init__(message)
self.cwd = cwd if cwd in locals():
self.cwd = cwd

View File

@@ -1,24 +1,36 @@
from __future__ import annotations from __future__ import annotations
from .get_changelog import GitChangelogQueryError, get_changelog
from .get_config_value import get_config_value
from .get_current_branch import get_current_branch from .get_current_branch import get_current_branch
from .get_head_commit import get_head_commit from .get_head_commit import get_head_commit
from .get_latest_commit import get_latest_commit from .get_latest_commit import get_latest_commit
from .get_tags import get_tags from .get_latest_signing_key import (
from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch GitLatestSigningKeyQueryError,
from .list_remotes import list_remotes get_latest_signing_key,
)
from .get_remote_head_commit import (
GitRemoteHeadCommitQueryError,
get_remote_head_commit,
)
from .get_remote_push_urls import get_remote_push_urls from .get_remote_push_urls import get_remote_push_urls
from .probe_remote_reachable import probe_remote_reachable
from .get_changelog import get_changelog, GitChangelogQueryError
from .get_tags_at_ref import get_tags_at_ref, GitTagsAtRefQueryError
from .get_config_value import get_config_value
from .get_upstream_ref import get_upstream_ref
from .list_tags import list_tags
from .get_repo_root import get_repo_root from .get_repo_root import get_repo_root
from .get_tags import get_tags
from .get_tags_at_ref import GitTagsAtRefQueryError, get_tags_at_ref
from .get_upstream_ref import get_upstream_ref
from .list_remotes import list_remotes
from .list_tags import list_tags
from .probe_remote_reachable import probe_remote_reachable
from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch
__all__ = [ __all__ = [
"get_current_branch", "get_current_branch",
"get_head_commit", "get_head_commit",
"get_latest_commit", "get_latest_commit",
"get_latest_signing_key",
"GitLatestSigningKeyQueryError",
"get_remote_head_commit",
"GitRemoteHeadCommitQueryError",
"get_tags", "get_tags",
"resolve_base_branch", "resolve_base_branch",
"GitBaseBranchNotFoundError", "GitBaseBranchNotFoundError",

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitQueryError, GitRunError
from ..run import run from ..run import run
class GitChangelogQueryError(GitError): class GitChangelogQueryError(GitQueryError):
"""Raised when querying the git changelog fails.""" """Raised when querying the git changelog fails."""
@@ -38,7 +38,7 @@ def get_changelog(
try: try:
return run(cmd, cwd=cwd) return run(cmd, cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitChangelogQueryError( raise GitChangelogQueryError(
f"Failed to query changelog for range {rev_range!r}.", f"Failed to query changelog for range {rev_range!r}.",
) from exc ) from exc

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
def _is_missing_key_error(exc: GitError) -> bool: def _is_missing_key_error(exc: GitRunError) -> bool:
msg = str(exc).lower() msg = str(exc).lower()
# Ensure we only swallow the expected case for THIS command. # Ensure we only swallow the expected case for THIS command.
@@ -25,7 +25,7 @@ def get_config_value(key: str, *, cwd: str = ".") -> Optional[str]:
""" """
try: try:
output = run(["config", "--get", key], cwd=cwd) output = run(["config", "--get", key], cwd=cwd)
except GitError as exc: except GitRunError as exc:
if _is_missing_key_error(exc): if _is_missing_key_error(exc):
return None return None
raise raise

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -13,6 +13,6 @@ def get_current_branch(cwd: str = ".") -> Optional[str]:
""" """
try: try:
output = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd) output = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
except GitError: except GitRunError:
return None return None
return output or None return output or None

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -12,6 +12,6 @@ def get_head_commit(cwd: str = ".") -> Optional[str]:
""" """
try: try:
output = run(["rev-parse", "HEAD"], cwd=cwd) output = run(["rev-parse", "HEAD"], cwd=cwd)
except GitError: except GitRunError:
return None return None
return output or None return output or None

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -19,7 +19,7 @@ def get_latest_commit(cwd: str = ".") -> Optional[str]:
""" """
try: try:
output = run(["log", "-1", "--format=%H"], cwd=cwd) output = run(["log", "-1", "--format=%H"], cwd=cwd)
except GitError: except GitRunError:
return None return None
output = output.strip() output = output.strip()

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from ..errors import GitQueryError, GitRunError
from ..run import run
class GitLatestSigningKeyQueryError(GitQueryError):
"""Raised when querying the latest commit signing key fails."""
def get_latest_signing_key(*, cwd: str = ".") -> str:
"""
Return the GPG signing key ID of the latest commit, via:
git log -1 --format=%GK
Returns:
The key id string (may be empty if commit is not signed).
"""
try:
return run(["log", "-1", "--format=%GK"], cwd=cwd).strip()
except GitRunError as exc:
raise GitLatestSigningKeyQueryError(
"Failed to query latest signing key.",
) from exc

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from ..errors import GitQueryError, GitRunError
from ..run import run
class GitRemoteHeadCommitQueryError(GitQueryError):
"""Raised when querying the remote HEAD commit fails."""
def get_remote_head_commit(
*,
remote: str = "origin",
ref: str = "HEAD",
cwd: str = ".",
) -> str:
"""
Return the commit hash for <remote> <ref> via:
git ls-remote <remote> <ref>
Returns:
The commit hash string (may be empty if remote/ref yields no output).
"""
try:
out = run(["ls-remote", remote, ref], cwd=cwd).strip()
except GitRunError as exc:
raise GitRemoteHeadCommitQueryError(
f"Failed to query remote head commit for {remote!r} {ref!r}.",
) from exc
# minimal parsing: first token is the hash
return (out.split()[0].strip() if out else "")

View File

@@ -4,7 +4,6 @@ from typing import Set
from ..run import run from ..run import run
def get_remote_push_urls(remote: str, cwd: str = ".") -> Set[str]: def get_remote_push_urls(remote: str, cwd: str = ".") -> Set[str]:
""" """
Return all push URLs configured for a remote. Return all push URLs configured for a remote.
@@ -12,7 +11,7 @@ def get_remote_push_urls(remote: str, cwd: str = ".") -> Set[str]:
Equivalent to: Equivalent to:
git remote get-url --push --all <remote> git remote get-url --push --all <remote>
Raises GitError if the command fails. Raises GitBaseError if the command fails.
""" """
output = run(["remote", "get-url", "--push", "--all", remote], cwd=cwd) output = run(["remote", "get-url", "--push", "--all", remote], cwd=cwd)
if not output: if not output:

View File

@@ -1,9 +1,8 @@
# src/pkgmgr/core/git/queries/get_repo_root.py
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -16,7 +15,7 @@ def get_repo_root(*, cwd: str = ".") -> Optional[str]:
""" """
try: try:
out = run(["rev-parse", "--show-toplevel"], cwd=cwd) out = run(["rev-parse", "--show-toplevel"], cwd=cwd)
except GitError: except GitRunError:
return None return None
out = out.strip() out = out.strip()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List from typing import List
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -14,11 +14,10 @@ def get_tags(cwd: str = ".") -> List[str]:
""" """
try: try:
output = run(["tag"], cwd=cwd) output = run(["tag"], cwd=cwd)
except GitError as exc: except GitRunError as exc:
# If the repo is not a git repo, surface a clear error. # If the repo is not a git repo, surface a clear error.
if "not a git repository" in str(exc): if "not a git repository" in str(exc):
raise raise
# Otherwise, treat as "no tags" (e.g., empty stdout).
return [] return []
if not output: if not output:

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
from typing import List from typing import List
from ..errors import GitError from ..errors import GitQueryError, GitRunError
from ..run import run from ..run import run
class GitTagsAtRefQueryError(GitError): class GitTagsAtRefQueryError(GitQueryError):
"""Raised when querying tags for a ref fails.""" """Raised when querying tags for a ref fails."""
@@ -19,7 +19,7 @@ def get_tags_at_ref(ref: str, *, cwd: str = ".") -> List[str]:
""" """
try: try:
output = run(["tag", "--points-at", ref], cwd=cwd) output = run(["tag", "--points-at", ref], cwd=cwd)
except GitError as exc: except GitRunError as exc:
raise GitTagsAtRefQueryError( raise GitTagsAtRefQueryError(
f"Failed to query tags at ref {ref!r}.", f"Failed to query tags at ref {ref!r}.",
) from exc ) from exc

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -18,7 +18,7 @@ def get_upstream_ref(*, cwd: str = ".") -> Optional[str]:
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=cwd, cwd=cwd,
) )
except GitError: except GitRunError:
return None return None
out = out.strip() out = out.strip()

View File

@@ -9,7 +9,7 @@ def list_remotes(cwd: str = ".") -> List[str]:
""" """
Return a list of configured git remotes (e.g. ['origin', 'upstream']). Return a list of configured git remotes (e.g. ['origin', 'upstream']).
Raises GitError if the command fails. Raises GitBaseError if the command fails.
""" """
output = run(["remote"], cwd=cwd) output = run(["remote"], cwd=cwd)
if not output: if not output:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..errors import GitError from ..errors import GitRunError
from ..run import run from ..run import run
@@ -17,5 +17,5 @@ def probe_remote_reachable(url: str, cwd: str = ".") -> bool:
try: try:
run(["ls-remote", "--exit-code", url], cwd=cwd) run(["ls-remote", "--exit-code", url], cwd=cwd)
return True return True
except GitError: except GitRunError:
return False return False

View File

@@ -1,15 +1,14 @@
# src/pkgmgr/core/git/queries/resolve_base_branch.py
from __future__ import annotations from __future__ import annotations
from ..errors import GitError from ..errors import GitQueryError, GitRunError
from ..run import run from ..run import run
class GitBaseBranchNotFoundError(GitError): class GitBaseBranchNotFoundError(GitQueryError):
"""Raised when neither preferred nor fallback base branch exists.""" """Raised when neither preferred nor fallback base branch exists."""
def _is_branch_missing_error(exc: GitError) -> bool: def _is_branch_missing_error(exc: GitRunError) -> bool:
""" """
Heuristic: Detect errors that indicate the branch/ref does not exist. Heuristic: Detect errors that indicate the branch/ref does not exist.
@@ -46,15 +45,15 @@ def resolve_base_branch(
fall back to `fallback` (default: master). fall back to `fallback` (default: master).
Raises GitBaseBranchNotFoundError if neither exists. Raises GitBaseBranchNotFoundError if neither exists.
Raises GitError for other git failures (e.g., not a git repository). Raises GitRunError for other git failures (e.g., not a git repository).
""" """
last_missing_error: GitError | None = None last_missing_error: GitRunError | None = None
for candidate in (preferred, fallback): for candidate in (preferred, fallback):
try: try:
run(["rev-parse", "--verify", candidate], cwd=cwd) run(["rev-parse", "--verify", candidate], cwd=cwd)
return candidate return candidate
except GitError as exc: except GitRunError as exc:
if _is_branch_missing_error(exc): if _is_branch_missing_error(exc):
last_missing_error = exc last_missing_error = exc
continue continue

View File

@@ -3,7 +3,12 @@ from __future__ import annotations
import subprocess import subprocess
from typing import List from typing import List
from .errors import GitError from .errors import GitRunError, GitNotRepositoryError
def _is_not_repo_error(stderr: str) -> bool:
msg = (stderr or "").lower()
return "not a git repository" in msg
def run( def run(
@@ -17,7 +22,7 @@ def run(
If preview=True, the command is printed but NOT executed. If preview=True, the command is printed but NOT executed.
Raises GitError if execution fails. Raises GitRunError (or a subclass) if execution fails.
""" """
cmd = ["git"] + args cmd = ["git"] + args
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
@@ -36,11 +41,19 @@ def run(
text=True, text=True,
) )
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise GitError( stderr = exc.stderr or ""
if _is_not_repo_error(stderr):
raise GitNotRepositoryError(
f"Not a git repository: {cwd!r}\n"
f"Command: {cmd_str}\n"
f"STDERR:\n{stderr}"
) from exc
raise GitRunError(
f"Git command failed in {cwd!r}: {cmd_str}\n" f"Git command failed in {cwd!r}: {cmd_str}\n"
f"Exit code: {exc.returncode}\n" f"Exit code: {exc.returncode}\n"
f"STDOUT:\n{exc.stdout}\n" f"STDOUT:\n{exc.stdout}\n"
f"STDERR:\n{exc.stderr}" f"STDERR:\n{stderr}"
) from exc ) from exc
return result.stdout.strip() return result.stdout.strip()

View File

@@ -1,48 +1,37 @@
import subprocess from __future__ import annotations
from pkgmgr.core.git.queries import (
get_head_commit,
get_latest_signing_key,
get_remote_head_commit,
GitLatestSigningKeyQueryError,
GitRemoteHeadCommitQueryError,
)
def verify_repository(repo, repo_dir, mode="local", no_verification=False): def verify_repository(repo, repo_dir, mode="local", no_verification=False):
""" _ = no_verification
Verifies the repository based on its 'verified' field.
The 'verified' field can be a dictionary with the following keys:
commit: The expected commit hash.
gpg_keys: A list of valid GPG key IDs (at least one must match the signing key).
If mode == "pull", the remote HEAD commit is checked via "git ls-remote origin HEAD".
Otherwise (mode "local", used for install and clone), the local HEAD commit is checked via "git rev-parse HEAD".
Returns a tuple:
(verified_ok, error_details, commit_hash, signing_key)
- verified_ok: True if the verification passed (or no verification info is set), False otherwise.
- error_details: A list of error messages for any failed checks.
- commit_hash: The obtained commit hash.
- signing_key: The GPG key ID that signed the latest commit (obtained via "git log -1 --format=%GK").
"""
verified_info = repo.get("verified") verified_info = repo.get("verified")
if not verified_info:
# Nothing to verify. commit_hash = ""
commit_hash = "" signing_key = ""
signing_key = ""
# best-effort info collection
try:
if mode == "pull": if mode == "pull":
try: commit_hash = get_remote_head_commit(cwd=repo_dir)
result = subprocess.run("git ls-remote origin HEAD", cwd=repo_dir, shell=True, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
commit_hash = result.stdout.split()[0].strip()
except Exception:
commit_hash = ""
else: else:
try: commit_hash = get_head_commit(cwd=repo_dir) or ""
result = subprocess.run("git rev-parse HEAD", cwd=repo_dir, shell=True, check=True, except GitRemoteHeadCommitQueryError:
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) commit_hash = ""
commit_hash = result.stdout.strip()
except Exception: try:
commit_hash = "" signing_key = get_latest_signing_key(cwd=repo_dir)
try: except GitLatestSigningKeyQueryError:
result = subprocess.run(["git", "log", "-1", "--format=%GK"], cwd=repo_dir, shell=False, check=True, signing_key = ""
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
signing_key = result.stdout.strip() if not verified_info:
except Exception:
signing_key = ""
return True, [], commit_hash, signing_key return True, [], commit_hash, signing_key
expected_commit = None expected_commit = None
@@ -51,47 +40,42 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
expected_commit = verified_info.get("commit") expected_commit = verified_info.get("commit")
expected_gpg_keys = verified_info.get("gpg_keys") expected_gpg_keys = verified_info.get("gpg_keys")
else: else:
# If verified is a plain string, treat it as the expected commit.
expected_commit = verified_info expected_commit = verified_info
error_details = [] error_details: list[str] = []
# Get commit hash according to the mode. # strict retrieval when verification is configured
commit_hash = ""
if mode == "pull": if mode == "pull":
try: try:
result = subprocess.run("git ls-remote origin HEAD", cwd=repo_dir, shell=True, check=True, commit_hash = get_remote_head_commit(cwd=repo_dir)
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) except GitRemoteHeadCommitQueryError as exc:
commit_hash = result.stdout.split()[0].strip() error_details.append(str(exc))
except Exception as e: commit_hash = ""
error_details.append(f"Error retrieving remote commit: {e}")
else: else:
try: commit_hash = get_head_commit(cwd=repo_dir) or ""
result = subprocess.run("git rev-parse HEAD", cwd=repo_dir, shell=True, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
commit_hash = result.stdout.strip()
except Exception as e:
error_details.append(f"Error retrieving local commit: {e}")
# Get the signing key using "git log -1 --format=%GK"
signing_key = ""
try: try:
result = subprocess.run(["git", "log", "-1", "--format=%GK"], cwd=repo_dir, shell=False, check=True, signing_key = get_latest_signing_key(cwd=repo_dir)
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) except GitLatestSigningKeyQueryError as exc:
signing_key = result.stdout.strip() error_details.append(str(exc))
except Exception as e: signing_key = ""
error_details.append(f"Error retrieving signing key: {e}")
commit_check_passed = True commit_check_passed = True
gpg_check_passed = True gpg_check_passed = True
if expected_commit: if expected_commit:
if commit_hash != expected_commit: if not commit_hash:
commit_check_passed = False
error_details.append(f"Expected commit: {expected_commit}, but could not determine current commit.")
elif commit_hash != expected_commit:
commit_check_passed = False commit_check_passed = False
error_details.append(f"Expected commit: {expected_commit}, found: {commit_hash}") error_details.append(f"Expected commit: {expected_commit}, found: {commit_hash}")
if expected_gpg_keys: if expected_gpg_keys:
if signing_key not in expected_gpg_keys: if not signing_key:
gpg_check_passed = False
error_details.append(f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found.")
elif signing_key not in expected_gpg_keys:
gpg_check_passed = False gpg_check_passed = False
error_details.append(f"Expected one of GPG keys: {expected_gpg_keys}, found: {signing_key}") error_details.append(f"Expected one of GPG keys: {expected_gpg_keys}, found: {signing_key}")

View File

@@ -89,7 +89,7 @@ class TestIntegrationChangelogCommands(unittest.TestCase):
""" """
Run 'pkgmgr changelog HEAD~5..HEAD' inside the pkgmgr repo. Run 'pkgmgr changelog HEAD~5..HEAD' inside the pkgmgr repo.
Selbst wenn HEAD~5 nicht existiert, sollte der Befehl den Selbst wenn HEAD~5 nicht existiert, sollte der Befehl den
GitError intern behandeln und mit Exit-Code 0 beenden GitBaseError intern behandeln und mit Exit-Code 0 beenden
(es wird dann eine [ERROR]-Zeile gedruckt). (es wird dann eine [ERROR]-Zeile gedruckt).
Wird übersprungen, wenn das pkgmgr-Repo nicht lokal vorhanden ist. Wird übersprungen, wenn das pkgmgr-Repo nicht lokal vorhanden ist.

View File

@@ -75,12 +75,12 @@ class TestCreateRepoPypiNotInGitConfig(unittest.TestCase):
mirrors_content = mirrors_file.read_text(encoding="utf-8") mirrors_content = mirrors_file.read_text(encoding="utf-8")
self.assertIn( self.assertIn(
"pypi https://pypi.org/project/repo/", "https://pypi.org/project/repo/",
mirrors_content, mirrors_content,
"PyPI mirror entry must exist in MIRRORS", "PyPI mirror entry must exist in MIRRORS",
) )
self.assertIn( self.assertIn(
"origin git@github.com:acme/repo.git", "git@github.com:acme/repo.git",
mirrors_content, mirrors_content,
"origin SSH URL must exist in MIRRORS", "origin SSH URL must exist in MIRRORS",
) )

View File

@@ -2,7 +2,7 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.branch.close_branch import close_branch from pkgmgr.actions.branch.close_branch import close_branch
from pkgmgr.core.git.errors import GitError from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
@@ -90,7 +90,7 @@ class TestCloseBranch(unittest.TestCase):
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False) delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".") delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitError("fail")) @patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitRunError("fail"))
def test_close_branch_errors_if_cannot_detect_branch(self, _current) -> None: def test_close_branch_errors_if_cannot_detect_branch(self, _current) -> None:
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
close_branch(None) close_branch(None)

View File

@@ -2,7 +2,7 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.branch.drop_branch import drop_branch from pkgmgr.actions.branch.drop_branch import drop_branch
from pkgmgr.core.git.errors import GitError from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
@@ -50,7 +50,7 @@ class TestDropBranch(unittest.TestCase):
delete_local.assert_called_once_with("feature-x", cwd=".", force=False) delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".") delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitError("fail")) @patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitRunError("fail"))
def test_drop_branch_errors_if_no_branch_detected(self, _current) -> None: def test_drop_branch_errors_if_no_branch_detected(self, _current) -> None:
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
drop_branch(None) drop_branch(None)

View File

@@ -0,0 +1,25 @@
import unittest
from unittest.mock import patch
from pkgmgr.core.git.errors import GitNotRepositoryError, GitRunError
from pkgmgr.core.git.queries.get_latest_signing_key import (
GitLatestSigningKeyQueryError,
get_latest_signing_key,
)
class TestGetLatestSigningKey(unittest.TestCase):
@patch("pkgmgr.core.git.queries.get_latest_signing_key.run", return_value="ABCDEF1234567890\n")
def test_strips_output(self, _mock_run) -> None:
out = get_latest_signing_key(cwd="/tmp/repo")
self.assertEqual(out, "ABCDEF1234567890")
@patch("pkgmgr.core.git.queries.get_latest_signing_key.run", side_effect=GitRunError("boom"))
def test_wraps_git_run_error(self, _mock_run) -> None:
with self.assertRaises(GitLatestSigningKeyQueryError):
get_latest_signing_key(cwd="/tmp/repo")
@patch("pkgmgr.core.git.queries.get_latest_signing_key.run", side_effect=GitNotRepositoryError("no repo"))
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
with self.assertRaises(GitNotRepositoryError):
get_latest_signing_key(cwd="/tmp/no-repo")

View File

@@ -0,0 +1,32 @@
import unittest
from unittest.mock import patch
from pkgmgr.core.git.errors import GitNotRepositoryError, GitRunError
from pkgmgr.core.git.queries.get_remote_head_commit import (
GitRemoteHeadCommitQueryError,
get_remote_head_commit,
)
class TestGetRemoteHeadCommit(unittest.TestCase):
@patch("pkgmgr.core.git.queries.get_remote_head_commit.run", return_value="abc123\tHEAD\n")
def test_parses_first_token_as_hash(self, mock_run) -> None:
out = get_remote_head_commit(cwd="/tmp/repo")
self.assertEqual(out, "abc123")
mock_run.assert_called_once()
@patch("pkgmgr.core.git.queries.get_remote_head_commit.run", return_value="")
def test_returns_empty_string_on_empty_output(self, _mock_run) -> None:
out = get_remote_head_commit(cwd="/tmp/repo")
self.assertEqual(out, "")
@patch("pkgmgr.core.git.queries.get_remote_head_commit.run", side_effect=GitRunError("boom"))
def test_wraps_git_run_error(self, _mock_run) -> None:
with self.assertRaises(GitRemoteHeadCommitQueryError) as ctx:
get_remote_head_commit(cwd="/tmp/repo")
self.assertIn("Failed to query remote head commit", str(ctx.exception))
@patch("pkgmgr.core.git.queries.get_remote_head_commit.run", side_effect=GitNotRepositoryError("no repo"))
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
with self.assertRaises(GitNotRepositoryError):
get_remote_head_commit(cwd="/tmp/no-repo")

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.core.git import GitError from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries.probe_remote_reachable import probe_remote_reachable from pkgmgr.core.git.queries.probe_remote_reachable import probe_remote_reachable
@@ -32,7 +32,7 @@ class TestProbeRemoteReachable(unittest.TestCase):
@patch("pkgmgr.core.git.queries.probe_remote_reachable.run") @patch("pkgmgr.core.git.queries.probe_remote_reachable.run")
def test_probe_remote_reachable_failure_returns_false(self, mock_run) -> None: def test_probe_remote_reachable_failure_returns_false(self, mock_run) -> None:
mock_run.side_effect = GitError("Git command failed (simulated)") mock_run.side_effect = GitRunError("Git command failed (simulated)")
ok = probe_remote_reachable( ok = probe_remote_reachable(
"ssh://git@code.example.org:2201/alice/repo.git", "ssh://git@code.example.org:2201/alice/repo.git",

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.core.git import GitError from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries.resolve_base_branch import ( from pkgmgr.core.git.queries.resolve_base_branch import (
GitBaseBranchNotFoundError, GitBaseBranchNotFoundError,
resolve_base_branch, resolve_base_branch,
@@ -21,7 +21,7 @@ class TestResolveBaseBranch(unittest.TestCase):
@patch("pkgmgr.core.git.queries.resolve_base_branch.run") @patch("pkgmgr.core.git.queries.resolve_base_branch.run")
def test_resolves_fallback(self, mock_run): def test_resolves_fallback(self, mock_run):
mock_run.side_effect = [ mock_run.side_effect = [
GitError("fatal: Needed a single revision"), # treat as "missing" GitRunError("fatal: Needed a single revision"), # treat as "missing"
"dummy", "dummy",
] ]
result = resolve_base_branch("main", "master", cwd=".") result = resolve_base_branch("main", "master", cwd=".")
@@ -32,8 +32,8 @@ class TestResolveBaseBranch(unittest.TestCase):
@patch("pkgmgr.core.git.queries.resolve_base_branch.run") @patch("pkgmgr.core.git.queries.resolve_base_branch.run")
def test_raises_when_no_branch_exists(self, mock_run): def test_raises_when_no_branch_exists(self, mock_run):
mock_run.side_effect = [ mock_run.side_effect = [
GitError("fatal: Needed a single revision"), GitRunError("fatal: Needed a single revision"),
GitError("fatal: Needed a single revision"), GitRunError("fatal: Needed a single revision"),
] ]
with self.assertRaises(GitBaseBranchNotFoundError): with self.assertRaises(GitBaseBranchNotFoundError):
resolve_base_branch("main", "master", cwd=".") resolve_base_branch("main", "master", cwd=".")

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pkgmgr.core.git.errors import GitError from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.run import run from pkgmgr.core.git.run import run
@@ -55,7 +55,7 @@ class TestGitRun(unittest.TestCase):
exc.stderr = "ERR!" exc.stderr = "ERR!"
with patch("pkgmgr.core.git.run.subprocess.run", side_effect=exc): with patch("pkgmgr.core.git.run.subprocess.run", side_effect=exc):
with self.assertRaises(GitError) as ctx: with self.assertRaises(GitRunError) as ctx:
run(["status"], cwd="/bad/repo", preview=False) run(["status"], cwd="/bad/repo", preview=False)
msg = str(ctx.exception) msg = str(ctx.exception)

View File

@@ -0,0 +1,87 @@
import unittest
from unittest.mock import patch
from pkgmgr.core.git.errors import GitNotRepositoryError
from pkgmgr.core.git.queries.get_latest_signing_key import GitLatestSigningKeyQueryError
from pkgmgr.core.git.queries.get_remote_head_commit import GitRemoteHeadCommitQueryError
from pkgmgr.core.repository.verify import verify_repository
class TestVerifyRepository(unittest.TestCase):
def test_no_verified_info_returns_ok_and_best_effort_values(self) -> None:
repo = {"id": "demo"} # no "verified"
with patch("pkgmgr.core.repository.verify.get_head_commit", return_value="deadbeef"), patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
return_value="KEYID",
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
self.assertTrue(ok)
self.assertEqual(errors, [])
self.assertEqual(commit, "deadbeef")
self.assertEqual(key, "KEYID")
def test_best_effort_swallows_query_errors_when_no_verified_info(self) -> None:
repo = {"id": "demo"}
with patch(
"pkgmgr.core.repository.verify.get_head_commit",
return_value=None,
), patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
side_effect=GitLatestSigningKeyQueryError("fail signing key"),
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
self.assertTrue(ok)
self.assertEqual(errors, [])
self.assertEqual(commit, "")
self.assertEqual(key, "")
def test_verified_commit_mismatch_fails(self) -> None:
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
with patch("pkgmgr.core.repository.verify.get_head_commit", return_value="actual"), patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
return_value="",
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
self.assertFalse(ok)
self.assertIn("Expected commit: expected, found: actual", errors)
self.assertEqual(commit, "actual")
self.assertEqual(key, "")
def test_verified_gpg_key_missing_fails(self) -> None:
repo = {"verified": {"commit": None, "gpg_keys": ["ABC"]}}
with patch("pkgmgr.core.repository.verify.get_head_commit", return_value=""), patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
return_value="",
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
self.assertFalse(ok)
self.assertTrue(any("no signing key was found" in e for e in errors))
self.assertEqual(commit, "")
self.assertEqual(key, "")
def test_strict_pull_collects_remote_error_message(self) -> None:
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
with patch(
"pkgmgr.core.repository.verify.get_remote_head_commit",
side_effect=GitRemoteHeadCommitQueryError("remote fail"),
), patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
return_value="",
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="pull")
self.assertFalse(ok)
self.assertIn("remote fail", " ".join(errors))
self.assertEqual(commit, "")
self.assertEqual(key, "")
def test_not_repository_error_is_not_caught(self) -> None:
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
with patch(
"pkgmgr.core.repository.verify.get_head_commit",
side_effect=GitNotRepositoryError("no repo"),
):
with self.assertRaises(GitNotRepositoryError):
verify_repository(repo, "/tmp/no-repo", mode="local")

View File

@@ -4,7 +4,7 @@
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.core.git.errors import GitError from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.run import run from pkgmgr.core.git.run import run
from pkgmgr.core.git.queries import get_tags, get_head_commit, get_current_branch from pkgmgr.core.git.queries import get_tags, get_head_commit, get_current_branch
@@ -35,7 +35,7 @@ class TestGitRun(unittest.TestCase):
stderr="error\n", stderr="error\n",
) )
with self.assertRaises(GitError) as ctx: with self.assertRaises(GitRunError) as ctx:
run(["status"], cwd="/tmp/repo") run(["status"], cwd="/tmp/repo")
msg = str(ctx.exception) msg = str(ctx.exception)
@@ -66,7 +66,7 @@ class TestGitQueries(unittest.TestCase):
@patch("pkgmgr.core.git.queries.get_head_commit.run") @patch("pkgmgr.core.git.queries.get_head_commit.run")
def test_get_head_commit_failure_returns_none(self, mock_run): def test_get_head_commit_failure_returns_none(self, mock_run):
mock_run.side_effect = GitError("fail") mock_run.side_effect = GitRunError("fail")
commit = get_head_commit(cwd="/tmp/repo") commit = get_head_commit(cwd="/tmp/repo")
self.assertIsNone(commit) self.assertIsNone(commit)
@@ -78,7 +78,7 @@ class TestGitQueries(unittest.TestCase):
@patch("pkgmgr.core.git.queries.get_current_branch.run") @patch("pkgmgr.core.git.queries.get_current_branch.run")
def test_get_current_branch_failure_returns_none(self, mock_run): def test_get_current_branch_failure_returns_none(self, mock_run):
mock_run.side_effect = GitError("fail") mock_run.side_effect = GitRunError("fail")
branch = get_current_branch(cwd="/tmp/repo") branch = get_current_branch(cwd="/tmp/repo")
self.assertIsNone(branch) self.assertIsNone(branch)