Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08738d812e | ||
|
|
21cecf64fb | ||
|
|
d1a951ee94 | ||
|
|
f40cc5cfde | ||
|
|
944b4cb663 | ||
|
|
919e6a6434 | ||
|
|
4a4bf137ac | ||
|
|
ea82d91b75 | ||
|
|
1c0a4abcbb | ||
|
|
810d27badc | ||
|
|
d823a42edc | ||
|
|
168d59e1b2 |
No files matched your search
@@ -6,13 +6,17 @@ on:
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
prepare:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
outputs:
|
||||
should_publish: ${{ steps.info.outputs.should_publish }}
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
is_stable: ${{ steps.info.outputs.is_stable }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository (with tags)
|
||||
@@ -36,24 +40,87 @@ jobs:
|
||||
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.should_publish == 'true' }}
|
||||
runs-on: ${{ matrix.target.runner }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: &distros [arch, debian, ubuntu, fedora, centos]
|
||||
target:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout workflow_run commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish all images
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
- name: Build and push ${{ matrix.distro }} for ${{ matrix.target.platform }}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
VERSION: ${{ steps.info.outputs.version }}
|
||||
IS_STABLE: ${{ steps.info.outputs.is_stable }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PKGMGR_DISTRO: ${{ matrix.distro }}
|
||||
PLATFORM: ${{ matrix.target.platform }}
|
||||
run: bash scripts/github/publish-containers/publish-container-images.sh
|
||||
|
||||
merge:
|
||||
needs: [prepare, build]
|
||||
if: ${{ !cancelled() && needs.prepare.outputs.should_publish == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: *distros
|
||||
|
||||
steps:
|
||||
- name: Checkout workflow_run commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Join both platforms of ${{ matrix.distro }} under the release tags
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
IS_STABLE: ${{ needs.prepare.outputs.is_stable }}
|
||||
PKGMGR_DISTRO: ${{ matrix.distro }}
|
||||
run: bash scripts/github/publish-containers/merge-container-images.sh
|
||||
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0] - 2026-11-01
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *gc --merged-into REF* treats items merged into REF as merged
|
||||
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
|
||||
- Branch: *--sizes* adds each worktree's disk usage to the plan
|
||||
- Branch: *--min-age HOURS* skips items with a commit or index that recent
|
||||
|
||||
## [2.2.0] - 2026-11-01
|
||||
|
||||
Added:
|
||||
|
||||
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||
|
||||
Changed:
|
||||
|
||||
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||
- Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||
- Push: without a selection it pushes the current checkout or worktree
|
||||
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||
|
||||
Fixed:
|
||||
|
||||
- Container: images build on native amd64 and arm64 runners, not QEMU
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
## [2.1.1] - 2026-10-31
|
||||
|
||||
Fixed:
|
||||
|
||||
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
## [2.1.0] - 2026-10-31
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||
- Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||
|
||||
Changed:
|
||||
|
||||
- Release: starts from the highest version in the tag and all version files
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
## [2.0.5] - 2026-10-29
|
||||
|
||||
Fixed:
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "2.0.5";
|
||||
version = "2.3.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=2.0.5
|
||||
pkgver=2.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,76 @@
|
||||
package-manager (2.3.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *gc --merged-into REF* treats items merged into REF as merged
|
||||
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
|
||||
- Branch: *--sizes* adds each worktree's disk usage to the plan
|
||||
- Branch: *--min-age HOURS* skips items with a commit or index that recent
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 15:14:53 +0100
|
||||
|
||||
package-manager (2.2.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||
|
||||
Changed:
|
||||
|
||||
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||
- Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||
- Push: without a selection it pushes the current checkout or worktree
|
||||
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||
|
||||
Fixed:
|
||||
|
||||
- Container: images build on native amd64 and arm64 runners, not QEMU
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 01:55:29 +0100
|
||||
|
||||
package-manager (2.1.1-1) unstable; urgency=medium
|
||||
|
||||
Fixed:
|
||||
|
||||
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 18:47:35 +0100
|
||||
|
||||
package-manager (2.1.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||
- Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||
|
||||
Changed:
|
||||
|
||||
- Release: starts from the highest version in the tag and all version files
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Release: a version file ahead of the newest tag now raises the next version
|
||||
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 14:38:54 +0100
|
||||
|
||||
package-manager (2.0.5-1) unstable; urgency=medium
|
||||
|
||||
Fixed:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 2.0.5
|
||||
Version: 2.3.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,67 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.3.0-1
|
||||
- Added:
|
||||
|
||||
- - Branch: *gc --merged-into REF* treats items merged into REF as merged
|
||||
- - Branch: *--merged-into* is repeatable, protects REF and works for *purge*
|
||||
- - Branch: *--sizes* adds each worktree's disk usage to the plan
|
||||
- - Branch: *--min-age HOURS* skips items with a commit or index that recent
|
||||
|
||||
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.2.0-1
|
||||
- Added:
|
||||
|
||||
- - Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||
- - Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||
|
||||
- Changed:
|
||||
|
||||
- - Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||
- - Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||
- - Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||
- - Push: without a selection it pushes the current checkout or worktree
|
||||
- - Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||
|
||||
- Fixed:
|
||||
|
||||
- - Container: images build on native amd64 and arm64 runners, not QEMU
|
||||
|
||||
- Upgrade notes:
|
||||
|
||||
- - Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||
- - Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||
- - Release: a version file ahead of the newest tag now raises the next version
|
||||
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
|
||||
- Fixed:
|
||||
|
||||
- - Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||
|
||||
- Upgrade notes:
|
||||
|
||||
- - Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||
- - Release: a version file ahead of the newest tag now raises the next version
|
||||
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.0-1
|
||||
- Added:
|
||||
|
||||
- - Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||
- - Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||
- - Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||
- - Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||
|
||||
- Changed:
|
||||
|
||||
- - Release: starts from the highest version in the tag and all version files
|
||||
|
||||
- Upgrade notes:
|
||||
|
||||
- - Release: a version file ahead of the newest tag now raises the next version
|
||||
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||
|
||||
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
|
||||
- Fixed:
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "2.0.5"
|
||||
version = "2.3.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
+45
-5
@@ -18,6 +18,8 @@ OWNER="" # e.g. github org/user
|
||||
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
|
||||
VERSION="" # X.Y.Z (required for --publish)
|
||||
IS_STABLE="false" # "true" -> publish stable tags
|
||||
PLATFORM="" # e.g. linux/arm64 -> build and push only this platform
|
||||
MERGE=0 # if 1 -> join the per-platform tags of --version under the publish tags
|
||||
DEFAULT_DISTRO="arch"
|
||||
|
||||
usage() {
|
||||
@@ -39,9 +41,12 @@ Publish options:
|
||||
--repo-prefix <name> Image base name (default: pkgmgr)
|
||||
--version <X.Y.Z> Version for --publish
|
||||
--stable <true|false> Whether to publish :stable tags (default: false)
|
||||
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>
|
||||
--merge Join the <version>-<arch> tags of all platforms under the publish tags
|
||||
|
||||
Notes:
|
||||
- --publish implies --push and requires --registry, --owner, and --version.
|
||||
- --platform requires --push; --merge requires --publish and builds nothing.
|
||||
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
|
||||
EOF
|
||||
}
|
||||
@@ -82,6 +87,12 @@ while [[ $# -gt 0 ]]; do
|
||||
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="${2:-}"
|
||||
[[ -n "${PLATFORM}" ]] || { echo "ERROR: --platform requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--merge) MERGE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
@@ -117,6 +128,19 @@ if [[ "${PUBLISH}" == "1" ]]; then
|
||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
|
||||
fi
|
||||
|
||||
if [[ -n "${PLATFORM}" ]]; then
|
||||
[[ "${PUSH}" == "1" ]] || { echo "ERROR: --platform requires --push or --publish"; exit 2; }
|
||||
[[ ",$(resolve_platforms "$PKGMGR_DISTRO")," == *",${PLATFORM},"* ]] \
|
||||
|| { echo "ERROR: ${PKGMGR_DISTRO} offers no platform ${PLATFORM}"; exit 2; }
|
||||
fi
|
||||
|
||||
if [[ "${MERGE}" == "1" ]]; then
|
||||
[[ "${PUBLISH}" == "1" ]] || { echo "ERROR: --merge requires --publish"; exit 2; }
|
||||
[[ -z "${PLATFORM}" ]] || { echo "ERROR: --merge and --platform exclude each other"; exit 2; }
|
||||
fi
|
||||
|
||||
DISTRO_TAG_BASE="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||
|
||||
# Guard: --push without --publish requires fully-qualified --tag
|
||||
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
|
||||
if [[ "${IMAGE_TAG}" != */* ]]; then
|
||||
@@ -130,7 +154,7 @@ echo "------------------------------------------------------------"
|
||||
echo "[build] Building image"
|
||||
echo "distro = ${PKGMGR_DISTRO}"
|
||||
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
||||
echo "platforms = $(resolve_platforms "$PKGMGR_DISTRO")"
|
||||
echo "platforms = ${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}"
|
||||
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
||||
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
@@ -150,7 +174,7 @@ if [[ "${NO_CACHE}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
compute_publish_tags() {
|
||||
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||
local distro_tag_base="${DISTRO_TAG_BASE}"
|
||||
local alias_tag_base=""
|
||||
|
||||
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
||||
@@ -176,10 +200,26 @@ compute_publish_tags() {
|
||||
printf '%s\n' "${tags[@]}"
|
||||
}
|
||||
|
||||
if [[ "${PUSH}" == "1" ]]; then
|
||||
if [[ "${MERGE}" == "1" ]]; then
|
||||
merge_args=(docker buildx imagetools create)
|
||||
while IFS= read -r t; do
|
||||
merge_args+=(-t "$t")
|
||||
done < <(compute_publish_tags)
|
||||
|
||||
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
|
||||
for p in "${platforms[@]}"; do
|
||||
merge_args+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
|
||||
done
|
||||
|
||||
echo "[build] Running: ${merge_args[*]}"
|
||||
"${merge_args[@]}"
|
||||
docker buildx imagetools inspect "${DISTRO_TAG_BASE}:${VERSION}"
|
||||
elif [[ "${PUSH}" == "1" ]]; then
|
||||
bx_args=(docker buildx build --push)
|
||||
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
|
||||
bx_args+=(-t "${DISTRO_TAG_BASE}:${VERSION}-${PLATFORM#*/}")
|
||||
elif [[ "${PUBLISH}" == "1" ]]; then
|
||||
while IFS= read -r t; do
|
||||
bx_args+=(-t "$t")
|
||||
done < <(compute_publish_tags)
|
||||
@@ -187,7 +227,7 @@ if [[ "${PUSH}" == "1" ]]; then
|
||||
bx_args+=(-t "${IMAGE_TAG}")
|
||||
fi
|
||||
|
||||
bx_args+=(--platform "$(resolve_platforms "$PKGMGR_DISTRO")")
|
||||
bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
|
||||
bx_args+=("${build_args[@]}")
|
||||
bx_args+=(.)
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# shellcheck source=./scripts/build/base.sh
|
||||
source "${SCRIPT_DIR}/base.sh"
|
||||
|
||||
REGISTRY="${REGISTRY:-ghcr.io}"
|
||||
IS_STABLE="${IS_STABLE:-false}"
|
||||
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
|
||||
@@ -33,12 +36,23 @@ for d in ${DISTROS}; do
|
||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||
echo "============================================================"
|
||||
|
||||
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")"
|
||||
for p in "${platforms[@]}"; do
|
||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--platform "${p}"
|
||||
done
|
||||
|
||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--stable "${IS_STABLE}"
|
||||
--stable "${IS_STABLE}" \
|
||||
--merge
|
||||
done
|
||||
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${OWNER:?OWNER must be set}"
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
: "${IS_STABLE:?IS_STABLE must be set}"
|
||||
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
|
||||
|
||||
bash scripts/build/image.sh \
|
||||
--publish \
|
||||
--registry ghcr.io \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--stable "${IS_STABLE}" \
|
||||
--merge
|
||||
@@ -3,6 +3,12 @@ set -euo pipefail
|
||||
|
||||
: "${OWNER:?OWNER must be set}"
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
: "${IS_STABLE:?IS_STABLE must be set}"
|
||||
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
|
||||
: "${PLATFORM:?PLATFORM must be set}"
|
||||
|
||||
bash scripts/build/publish.sh
|
||||
bash scripts/build/image.sh \
|
||||
--publish \
|
||||
--registry ghcr.io \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--platform "${PLATFORM}"
|
||||
@@ -4,9 +4,12 @@ Public API for branch actions.
|
||||
|
||||
from .close_branch import close_branch
|
||||
from .drop_branch import drop_branch
|
||||
from .gc import branch_gc, branch_purge
|
||||
from .open_branch import open_branch
|
||||
|
||||
__all__ = [
|
||||
"branch_gc",
|
||||
"branch_purge",
|
||||
"close_branch",
|
||||
"drop_branch",
|
||||
"open_branch",
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from pkgmgr.core.git.commands import delete_local_branch
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import list_remotes, resolve_base_branch
|
||||
from pkgmgr.core.git.run import run
|
||||
|
||||
from .gc_forge import ForgeFacts, fetch_forge_facts
|
||||
|
||||
# PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
|
||||
INIT_PID_NAMESPACE = "pid:[4026531836]"
|
||||
CLEANUP_IMAGE = "alpine:3.20"
|
||||
COMPOSE_PROJECT = "com.docker.compose.project"
|
||||
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
|
||||
LOCK_PID = re.compile(r"\bpid (\d+)")
|
||||
ALWAYS_PROTECTED = ("main", "master")
|
||||
LABELS = {
|
||||
"merged-clean": "DELETE",
|
||||
"unmerged": "KEEP",
|
||||
"dirty": "KEEP",
|
||||
"protected": "PROTECT",
|
||||
"stale": "STALE",
|
||||
}
|
||||
REFUSAL = (
|
||||
"Refusing: not in the host PID namespace (e.g. a sandbox), so running "
|
||||
"processes and worktree locks cannot be verified. Run it on the host."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Worktree:
|
||||
path: str
|
||||
head: str | None
|
||||
branch: str | None
|
||||
locked: str | None
|
||||
prunable: bool
|
||||
main: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Container:
|
||||
project: str
|
||||
working_dir: str
|
||||
image: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
branch: str | None
|
||||
worktree: str | None = None
|
||||
local_sha: str | None = None
|
||||
remotes: dict[str, str] = field(default_factory=dict)
|
||||
compose_projects: list[str] = field(default_factory=list)
|
||||
images: list[str] = field(default_factory=list)
|
||||
state: str = ""
|
||||
merged_by: str | None = None
|
||||
merged_into: str | None = None
|
||||
size_bytes: int | None = None
|
||||
skip: str | None = None
|
||||
actions: list[str] = field(default_factory=list)
|
||||
reason: str = ""
|
||||
result: str | None = None
|
||||
|
||||
@property
|
||||
def eligible(self) -> bool:
|
||||
return self.state == "merged-clean" and self.skip is None and bool(self.actions)
|
||||
|
||||
def matches(self, name: str) -> bool:
|
||||
return name == self.branch or (
|
||||
self.worktree is not None and os.path.abspath(name) == self.worktree
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Repo:
|
||||
repo: str
|
||||
default: str
|
||||
remotes: list[str]
|
||||
protected: set[str]
|
||||
first_parent: set[str]
|
||||
forge: ForgeFacts
|
||||
containers: list[Container] | None
|
||||
host_ns: bool
|
||||
lines: dict[str, tuple[str, set[str]]]
|
||||
sizes: bool
|
||||
min_age: float | None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
items: list[Item] = field(default_factory=list)
|
||||
|
||||
|
||||
def host_pid_namespace() -> bool:
|
||||
try:
|
||||
return os.readlink("/proc/self/ns/pid") == INIT_PID_NAMESPACE
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def main_repo_root(cwd: str) -> str:
|
||||
return os.path.dirname(_common_dir(cwd))
|
||||
|
||||
|
||||
def _common_dir(cwd: str) -> str:
|
||||
return run(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd)
|
||||
|
||||
|
||||
def _docker(args: list[str]) -> str:
|
||||
return subprocess.run(
|
||||
["docker", *args], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def _describe(exc: Exception) -> str:
|
||||
if isinstance(exc, subprocess.CalledProcessError):
|
||||
return f"{' '.join(exc.cmd)}: {(exc.stderr or '').strip()}"
|
||||
return str(exc).strip().splitlines()[0]
|
||||
|
||||
|
||||
def list_compose_containers() -> list[Container] | None:
|
||||
fmt = (
|
||||
f'{{{{.Label "{COMPOSE_PROJECT}"}}}}\t'
|
||||
f'{{{{.Label "{COMPOSE_WORKING_DIR}"}}}}\t{{{{.Image}}}}'
|
||||
)
|
||||
try:
|
||||
out = _docker(
|
||||
["ps", "-a", "--filter", f"label={COMPOSE_WORKING_DIR}", "--format", fmt]
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
return [
|
||||
Container(*line.split("\t"))
|
||||
for line in out.splitlines()
|
||||
if line.count("\t") == 2
|
||||
]
|
||||
|
||||
|
||||
def _inside(path: str, root: str) -> bool:
|
||||
return path == root or path.startswith(root.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _worktrees(repo_root: str) -> list[Worktree]:
|
||||
trees: list[Worktree] = []
|
||||
for block in run(["worktree", "list", "--porcelain"], cwd=repo_root).split("\n\n"):
|
||||
fields: dict[str, str] = {}
|
||||
for line in block.splitlines():
|
||||
key, _, value = line.partition(" ")
|
||||
fields[key] = value
|
||||
if "worktree" not in fields:
|
||||
continue
|
||||
branch = fields.get("branch")
|
||||
trees.append(
|
||||
Worktree(
|
||||
path=fields["worktree"],
|
||||
head=fields.get("HEAD"),
|
||||
branch=branch.removeprefix("refs/heads/") if branch else None,
|
||||
locked=fields.get("locked"),
|
||||
prunable="prunable" in fields,
|
||||
main=not trees,
|
||||
)
|
||||
)
|
||||
return trees
|
||||
|
||||
|
||||
def _refs(
|
||||
repo_root: str, remotes: list[str]
|
||||
) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
|
||||
out = run(
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname) %(objectname)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
],
|
||||
cwd=repo_root,
|
||||
)
|
||||
by_length = sorted(remotes, key=len, reverse=True)
|
||||
local: dict[str, str] = {}
|
||||
remote: dict[str, dict[str, str]] = {}
|
||||
for line in out.splitlines():
|
||||
ref, sha = line.split(" ")
|
||||
if ref.startswith("refs/heads/"):
|
||||
local[ref.removeprefix("refs/heads/")] = sha
|
||||
continue
|
||||
rest = ref.removeprefix("refs/remotes/")
|
||||
owner = next((r for r in by_length if rest.startswith(r + "/")), None)
|
||||
if owner and rest[len(owner) + 1 :] != "HEAD":
|
||||
remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha
|
||||
return local, remote
|
||||
|
||||
|
||||
def _process_cwds() -> dict[int, str]:
|
||||
cwds: dict[int, str] = {}
|
||||
for entry in os.listdir("/proc"):
|
||||
if entry.isdigit() and int(entry) != os.getpid():
|
||||
try:
|
||||
cwds[int(entry)] = os.readlink(f"/proc/{entry}/cwd")
|
||||
except OSError:
|
||||
continue
|
||||
return cwds
|
||||
|
||||
|
||||
def _comm(pid: int) -> str:
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm", encoding="utf-8") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return "?"
|
||||
|
||||
|
||||
def _lock_skip(reason: str | None, host_ns: bool) -> str | None:
|
||||
if reason is None:
|
||||
return None
|
||||
match = LOCK_PID.search(reason)
|
||||
if not match:
|
||||
return f"lock: {reason or 'without reason'}"
|
||||
if not host_ns:
|
||||
return "lock: liveness unknown"
|
||||
pid = int(match.group(1))
|
||||
return f"lock: pid {pid} alive" if os.path.exists(f"/proc/{pid}") else None
|
||||
|
||||
|
||||
def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
|
||||
try:
|
||||
run(["merge-base", "--is-ancestor", sha, ref], cwd=repo_root)
|
||||
except GitRunError as exc:
|
||||
if exc.returncode == 1:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def _in_line(repo: Repo, sha: str) -> str | None:
|
||||
for line, (tip, first_parent) in repo.lines.items():
|
||||
if sha == tip:
|
||||
continue
|
||||
if sha in first_parent or _is_ancestor(sha, line, repo.repo):
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def _merged_by(repo: Repo, sha: str) -> str | None:
|
||||
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
|
||||
return "ancestor"
|
||||
number = repo.forge.merged_heads.get(sha)
|
||||
if number:
|
||||
return f"pr#{number}"
|
||||
line = _in_line(repo, sha)
|
||||
return f"line:{line}" if line else None
|
||||
|
||||
|
||||
def _du_bytes(path: str) -> int | None:
|
||||
out = subprocess.run(
|
||||
["du", "-sk", path], check=False, capture_output=True, text=True
|
||||
).stdout.split()
|
||||
return int(out[0]) * 1024 if out and out[0].isdigit() else None
|
||||
|
||||
|
||||
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
|
||||
stamps = [
|
||||
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
|
||||
]
|
||||
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
|
||||
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
|
||||
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
|
||||
if os.path.isfile(index):
|
||||
stamps.append(int(os.path.getmtime(index)))
|
||||
return (time.time() - max(stamps)) / 3600
|
||||
|
||||
|
||||
def _human(size: int) -> str:
|
||||
value = float(size)
|
||||
for unit in ("K", "M", "G"):
|
||||
value /= 1024
|
||||
if value < 1024:
|
||||
return f"{value:.1f}{unit}"
|
||||
return f"{value / 1024:.1f}T"
|
||||
|
||||
|
||||
def _skip(
|
||||
repo: Repo,
|
||||
item: Item,
|
||||
tree: Worktree | None,
|
||||
checkout: bool,
|
||||
cwds: dict[int, str],
|
||||
) -> str | None:
|
||||
number = repo.forge.open_heads.get(item.branch) if item.branch else None
|
||||
if number:
|
||||
return f"open-pr #{number}"
|
||||
if repo.forge.unavailable:
|
||||
return "forge unavailable"
|
||||
if tree is None:
|
||||
return None
|
||||
if tree.main:
|
||||
return "checked out in main worktree"
|
||||
if not checkout:
|
||||
return "worktree checkout missing"
|
||||
if repo.containers is None:
|
||||
return "docker unavailable"
|
||||
lock = _lock_skip(tree.locked, repo.host_ns)
|
||||
if lock:
|
||||
return lock
|
||||
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, tree.path))
|
||||
if busy:
|
||||
return f"process: pid {busy[0]} ({_comm(busy[0])})"
|
||||
return None
|
||||
|
||||
|
||||
def _build(
|
||||
repo: Repo,
|
||||
branch: str | None,
|
||||
tree: Worktree | None,
|
||||
snapshot: tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]],
|
||||
) -> Item:
|
||||
local, remote, cwds = snapshot
|
||||
item = Item(
|
||||
branch=branch,
|
||||
worktree=tree.path if tree else None,
|
||||
local_sha=local.get(branch) if branch else None,
|
||||
remotes=dict(remote.get(branch, {})) if branch else {},
|
||||
)
|
||||
if branch in repo.protected:
|
||||
item.state, item.reason = "protected", "default or protected branch"
|
||||
return item
|
||||
|
||||
if tree and repo.containers:
|
||||
mine = [c for c in repo.containers if _inside(c.working_dir, tree.path)]
|
||||
item.compose_projects = sorted({c.project for c in mine})
|
||||
item.images = sorted({c.image for c in mine})
|
||||
|
||||
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None]
|
||||
verdicts = {sha: _merged_by(repo, sha) for sha in dict.fromkeys(shas) if sha}
|
||||
unmerged = [sha for sha, verdict in verdicts.items() if verdict is None]
|
||||
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
|
||||
|
||||
try:
|
||||
dirty = (
|
||||
len(
|
||||
run(
|
||||
["status", "--porcelain", "--untracked-files=all"], cwd=tree.path
|
||||
).splitlines()
|
||||
)
|
||||
if checkout and not tree.main
|
||||
else 0
|
||||
)
|
||||
except GitRunError as exc:
|
||||
item.state, item.reason = "dirty", f"status failed: {_describe(exc)}"
|
||||
dirty = -1
|
||||
|
||||
if dirty > 0:
|
||||
item.state, item.reason = "dirty", f"dirty: {dirty} paths"
|
||||
elif dirty == 0 and unmerged:
|
||||
ahead = int(
|
||||
run(
|
||||
["rev-list", "--count", f"{repo.default}..{unmerged[0]}"], cwd=repo.repo
|
||||
)
|
||||
)
|
||||
item.state = "unmerged"
|
||||
item.reason = (
|
||||
f"unmerged: {ahead} commits not in {repo.default}"
|
||||
if ahead
|
||||
else "no own commits"
|
||||
)
|
||||
elif dirty == 0:
|
||||
item.state = "merged-clean"
|
||||
item.merged_by = next(
|
||||
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
|
||||
"ancestor",
|
||||
)
|
||||
item.merged_into = (
|
||||
item.merged_by.removeprefix("line:")
|
||||
if item.merged_by.startswith("line:")
|
||||
else repo.default
|
||||
)
|
||||
item.reason = f"merged by {item.merged_by}"
|
||||
|
||||
if repo.sizes and checkout and not tree.main:
|
||||
item.size_bytes = _du_bytes(tree.path)
|
||||
item.skip = _skip(repo, item, tree, checkout, cwds)
|
||||
if item.skip is None and repo.min_age is not None and verdicts:
|
||||
age = _age_hours(repo, list(verdicts), tree if checkout else None)
|
||||
if age < repo.min_age:
|
||||
item.skip = f"younger than {repo.min_age:g} h"
|
||||
for project in item.compose_projects:
|
||||
item.actions += [f"compose-down:{project}", f"images:{project}"]
|
||||
if tree:
|
||||
item.actions.append("worktree")
|
||||
if item.local_sha:
|
||||
item.actions.append("local-branch")
|
||||
item.actions += [f"remote-branch:{name}" for name in item.remotes]
|
||||
return item
|
||||
|
||||
|
||||
def _snapshot(
|
||||
repo: Repo,
|
||||
) -> tuple[
|
||||
list[Worktree], tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]]
|
||||
]:
|
||||
local, remote = _refs(repo.repo, repo.remotes)
|
||||
cwds = _process_cwds() if repo.host_ns else {}
|
||||
return _worktrees(repo.repo), (local, remote, cwds)
|
||||
|
||||
|
||||
def _items(repo: Repo) -> list[Item]:
|
||||
trees, snapshot = _snapshot(repo)
|
||||
local, remote, _ = snapshot
|
||||
by_branch = {t.branch: t for t in trees if t.branch}
|
||||
names = sorted(set(local) | set(remote) | set(by_branch))
|
||||
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
|
||||
items += [
|
||||
_build(repo, None, tree, snapshot)
|
||||
for tree in trees
|
||||
if tree.branch is None and not tree.main
|
||||
]
|
||||
return items + _stale(repo.repo)
|
||||
|
||||
|
||||
def _stale(repo_root: str) -> list[Item]:
|
||||
base = os.path.join(_common_dir(repo_root), "worktrees")
|
||||
items: list[Item] = []
|
||||
for entry in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
||||
meta = os.path.join(base, entry)
|
||||
gitdir = os.path.join(meta, "gitdir")
|
||||
if not os.path.isfile(gitdir) or os.path.getsize(gitdir) == 0:
|
||||
reason = "metadata without gitdir, not pruned"
|
||||
else:
|
||||
with open(gitdir, encoding="utf-8") as fh:
|
||||
target = fh.read().strip()
|
||||
if os.path.exists(target):
|
||||
continue
|
||||
reason = f"checkout {os.path.dirname(target)} missing, not pruned"
|
||||
items.append(Item(branch=None, worktree=meta, state="stale", reason=reason))
|
||||
return items
|
||||
|
||||
|
||||
def _lines(
|
||||
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
|
||||
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
|
||||
lines: dict[str, tuple[str, set[str]]] = {}
|
||||
names: set[str] = set()
|
||||
for ref in refs:
|
||||
try:
|
||||
tip = run(
|
||||
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root
|
||||
)
|
||||
except GitRunError:
|
||||
warnings.append(f"merged-into ref {ref} not found")
|
||||
continue
|
||||
first_parent = set(
|
||||
run(["rev-list", "--first-parent", ref], cwd=repo_root).splitlines()
|
||||
)
|
||||
lines[ref] = (tip, first_parent)
|
||||
owner = next((r for r in remotes if ref.startswith(r + "/")), None)
|
||||
names.add(ref[len(owner) + 1 :] if owner else ref)
|
||||
return lines, names
|
||||
|
||||
|
||||
def plan_repo(
|
||||
repo_root: str,
|
||||
containers: list[Container] | None,
|
||||
host_ns: bool,
|
||||
*,
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
) -> Repo:
|
||||
"""
|
||||
Scan one repository and classify every branch and worktree.
|
||||
|
||||
Args:
|
||||
repo_root: main worktree root of the repository.
|
||||
containers: compose containers on the host; None when docker is unavailable.
|
||||
host_ns: whether this process runs in the host PID namespace.
|
||||
merged_into: integration lines (refs) that also count as merged; each is protected.
|
||||
sizes: measure each worktree's disk usage into size_bytes.
|
||||
min_age: skip items whose newest commit or worktree index is younger (hours).
|
||||
|
||||
Returns:
|
||||
The repository with its classified items.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
||||
# sandboxed worktrees whose gitdir stub is missing.
|
||||
run(
|
||||
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
except GitRunError as exc:
|
||||
warnings.append(f"fetch failed: {_describe(exc)}")
|
||||
if containers is None:
|
||||
warnings.append("docker unavailable: worktree items are skipped")
|
||||
|
||||
remotes = sorted(list_remotes(cwd=repo_root), key=lambda r: (r != "origin", r))
|
||||
heads: dict[str, str] = {}
|
||||
for remote in remotes:
|
||||
try:
|
||||
heads[remote] = run(
|
||||
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
except GitRunError:
|
||||
continue
|
||||
default = next(iter(heads.values()), None) or resolve_base_branch(
|
||||
"main", "master", cwd=repo_root
|
||||
)
|
||||
head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
|
||||
|
||||
forge = fetch_forge_facts(
|
||||
{r: run(["remote", "get-url", r], cwd=repo_root) for r in remotes}
|
||||
)
|
||||
if forge.unavailable:
|
||||
warnings.append(f"forge unavailable: {forge.unavailable}")
|
||||
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
|
||||
|
||||
repo = Repo(
|
||||
repo=repo_root,
|
||||
default=default,
|
||||
remotes=remotes,
|
||||
protected={
|
||||
default,
|
||||
*ALWAYS_PROTECTED,
|
||||
*head_names,
|
||||
*forge.protected,
|
||||
*line_names,
|
||||
},
|
||||
first_parent=set(
|
||||
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
|
||||
),
|
||||
forge=forge,
|
||||
containers=containers,
|
||||
host_ns=host_ns,
|
||||
lines=lines,
|
||||
sizes=sizes,
|
||||
min_age=min_age,
|
||||
warnings=warnings,
|
||||
)
|
||||
repo.items = _items(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _foreign_owned(path: str) -> bool:
|
||||
uid = os.getuid()
|
||||
if os.lstat(path).st_uid != uid:
|
||||
return True
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in (*dirs, *files):
|
||||
if os.lstat(os.path.join(root, name)).st_uid != uid:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _remove_worktree(repo_root: str, path: str) -> None:
|
||||
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
|
||||
meta = os.path.normpath(
|
||||
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
|
||||
)
|
||||
if os.path.dirname(meta) != os.path.join(_common_dir(repo_root), "worktrees"):
|
||||
raise RuntimeError(f"unexpected worktree metadata path {meta}")
|
||||
|
||||
if _foreign_owned(path):
|
||||
parent, name = os.path.split(path.rstrip("/"))
|
||||
_docker(
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
f"{parent}:/w",
|
||||
CLEANUP_IMAGE,
|
||||
"rm",
|
||||
"-rf",
|
||||
f"/w/{name}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
shutil.rmtree(path)
|
||||
if os.path.lexists(path):
|
||||
raise RuntimeError(f"worktree {path} still exists; metadata {meta} kept")
|
||||
shutil.rmtree(meta)
|
||||
|
||||
|
||||
def _rebuild(repo: Repo, item: Item) -> Item:
|
||||
trees, snapshot = _snapshot(repo)
|
||||
tree = next(
|
||||
(
|
||||
t
|
||||
for t in trees
|
||||
if (t.branch == item.branch if item.branch else t.path == item.worktree)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return _build(repo, item.branch, tree, snapshot)
|
||||
|
||||
|
||||
def _fingerprint(item: Item) -> tuple:
|
||||
return (
|
||||
item.state,
|
||||
item.skip,
|
||||
item.local_sha,
|
||||
item.remotes,
|
||||
item.worktree,
|
||||
item.compose_projects,
|
||||
)
|
||||
|
||||
|
||||
def execute(repo: Repo, item: Item) -> None:
|
||||
fresh = _rebuild(repo, item)
|
||||
if _fingerprint(fresh) != _fingerprint(item):
|
||||
item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})"
|
||||
return
|
||||
|
||||
notes: list[str] = []
|
||||
try:
|
||||
for project in item.compose_projects:
|
||||
_docker(["compose", "-p", project, "down", "-v", "--remove-orphans"])
|
||||
for image in item.images:
|
||||
try:
|
||||
_docker(["image", "rm", image])
|
||||
except subprocess.CalledProcessError:
|
||||
notes.append(f"image kept: {image}")
|
||||
if item.worktree:
|
||||
_remove_worktree(repo.repo, item.worktree)
|
||||
if item.local_sha:
|
||||
delete_local_branch(item.branch, cwd=repo.repo, force=True)
|
||||
for remote, sha in item.remotes.items():
|
||||
run(
|
||||
[
|
||||
"push",
|
||||
f"--force-with-lease=refs/heads/{item.branch}:{sha}",
|
||||
remote,
|
||||
f":refs/heads/{item.branch}",
|
||||
],
|
||||
cwd=repo.repo,
|
||||
)
|
||||
except (subprocess.CalledProcessError, RuntimeError, OSError) as exc:
|
||||
item.result = f"failed: {_describe(exc)}"
|
||||
return
|
||||
item.result = "; ".join(["done", *notes])
|
||||
|
||||
|
||||
def _line(item: Item) -> str:
|
||||
label = "SKIP" if item.state == "merged-clean" and item.skip else LABELS[item.state]
|
||||
parts = [item.reason]
|
||||
if item.skip and item.state != "protected":
|
||||
parts.append(item.skip)
|
||||
if item.worktree and item.state != "stale":
|
||||
parts.append(f"worktree {item.worktree}")
|
||||
if item.size_bytes is not None:
|
||||
parts.append(f"size {_human(item.size_bytes)}")
|
||||
if item.compose_projects:
|
||||
parts.append("stack " + ", ".join(item.compose_projects))
|
||||
if item.remotes:
|
||||
parts.append("remotes " + ", ".join(item.remotes))
|
||||
line = f"{label:<8} {item.branch or item.worktree} {'; '.join(parts)}"
|
||||
return f"{line} -> {item.result}" if item.result else line
|
||||
|
||||
|
||||
def _render(
|
||||
repos: list[Repo], host_ns: bool, chosen: Callable[[Item], bool], as_json: bool
|
||||
) -> None:
|
||||
if as_json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"host_pid_namespace": host_ns,
|
||||
"repos": [
|
||||
{
|
||||
"repo": repo.repo,
|
||||
"default": repo.default,
|
||||
"merged_into": list(repo.lines),
|
||||
"warnings": repo.warnings,
|
||||
"items": [asdict(i) for i in repo.items if chosen(i)],
|
||||
}
|
||||
for repo in repos
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
for repo in repos:
|
||||
print(f"== {repo.repo} (default {repo.default})")
|
||||
for warning in repo.warnings:
|
||||
print(f"WARN {warning}")
|
||||
for item in repo.items:
|
||||
if chosen(item):
|
||||
print(_line(item))
|
||||
if not host_ns:
|
||||
print(
|
||||
"WARN process and lock checks unavailable: "
|
||||
"not in the host PID namespace"
|
||||
)
|
||||
|
||||
|
||||
def _prepare(
|
||||
repo_roots: list[str],
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
options: dict,
|
||||
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
|
||||
host_ns = host_pid_namespace()
|
||||
containers = list_compose_containers()
|
||||
repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots]
|
||||
unknown = [
|
||||
name
|
||||
for name in include
|
||||
if not any(item.matches(name) for repo in repos for item in repo.items)
|
||||
]
|
||||
if unknown:
|
||||
print(f"Unknown --branch: {', '.join(unknown)}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def chosen(item: Item) -> bool:
|
||||
return (not include or any(item.matches(n) for n in include)) and not any(
|
||||
item.matches(n) for n in exclude
|
||||
)
|
||||
|
||||
return repos, host_ns, chosen
|
||||
|
||||
|
||||
def _finish(repos: list[Repo]) -> int:
|
||||
results = [item.result for repo in repos for item in repo.items if item.result]
|
||||
if any(result.startswith("done") for result in results) and any(
|
||||
repo.containers is not None for repo in repos
|
||||
):
|
||||
for args in (["image", "prune", "-f"], ["builder", "prune", "-f"]):
|
||||
try:
|
||||
_docker(args)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
print(f"WARN {_describe(exc)}", file=sys.stderr)
|
||||
return 1 if any(result.startswith("failed") for result in results) else 0
|
||||
|
||||
|
||||
def branch_gc(
|
||||
repo_roots: list[str],
|
||||
*,
|
||||
apply: bool,
|
||||
as_json: bool,
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
) -> int:
|
||||
"""
|
||||
Plan, and with apply=True execute, the cleanup of merged and clean branches.
|
||||
|
||||
Args:
|
||||
repo_roots: main worktree roots of the repositories to scan.
|
||||
apply: execute the plan instead of only printing it.
|
||||
as_json: print the plan as JSON instead of text lines.
|
||||
include: only items whose branch or worktree path matches one of these.
|
||||
exclude: drop items whose branch or worktree path matches one of these.
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage into the plan.
|
||||
min_age: skip items younger than this many hours.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 when an applied item failed, 2 when refused.
|
||||
"""
|
||||
if apply and not host_pid_namespace():
|
||||
print(REFUSAL, file=sys.stderr)
|
||||
return 2
|
||||
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||
prepared = _prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, host_ns, chosen = prepared
|
||||
|
||||
if apply:
|
||||
for repo in repos:
|
||||
for item in repo.items:
|
||||
if item.eligible and chosen(item):
|
||||
execute(repo, item)
|
||||
print(
|
||||
f"[gc] {item.branch or item.worktree}: {item.result}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_render(repos, host_ns, chosen, as_json)
|
||||
return _finish(repos) if apply else 0
|
||||
|
||||
|
||||
def branch_purge(
|
||||
repo_roots: list[str],
|
||||
*,
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
) -> int:
|
||||
"""
|
||||
Delete unmerged or dirty items after a y/N confirmation per item.
|
||||
|
||||
Args:
|
||||
repo_roots: main worktree roots of the repositories to scan.
|
||||
include: only items whose branch or worktree path matches one of these.
|
||||
exclude: drop items whose branch or worktree path matches one of these.
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage into the prompt line.
|
||||
min_age: skip items younger than this many hours.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 when a confirmed item failed, 2 when refused.
|
||||
"""
|
||||
if not host_pid_namespace():
|
||||
print(REFUSAL, file=sys.stderr)
|
||||
return 2
|
||||
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||
prepared = _prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, _, chosen = prepared
|
||||
|
||||
for repo in repos:
|
||||
for item in repo.items:
|
||||
if item.state not in ("unmerged", "dirty") or item.skip or not chosen(item):
|
||||
continue
|
||||
print(_line(item))
|
||||
print(f" removes: {', '.join(item.actions)}")
|
||||
try:
|
||||
answer = input("Delete this item? This is destructive! (y/N): ")
|
||||
except EOFError:
|
||||
answer = ""
|
||||
if answer.strip().lower() != "y":
|
||||
continue
|
||||
execute(repo, item)
|
||||
print(f" -> {item.result}")
|
||||
return _finish(repos)
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pkgmgr.actions.mirror.url_utils import parse_repo_from_git_url
|
||||
from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver
|
||||
from pkgmgr.core.credentials.types import CredentialError
|
||||
from pkgmgr.core.remote_provisioning.http.client import HttpClient
|
||||
from pkgmgr.core.remote_provisioning.http.errors import HttpError
|
||||
|
||||
GITHUB_HOST = "github.com"
|
||||
GITHUB_API = "https://api.github.com"
|
||||
PAGE_SIZE = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeFacts:
|
||||
"""
|
||||
Pull-request and protection facts of every GitHub remote of one repository.
|
||||
|
||||
Attributes:
|
||||
merged_heads: head SHA of a merged PR -> PR number.
|
||||
open_heads: head branch name of an open PR -> PR number.
|
||||
protected: branch names protected on any of the remotes.
|
||||
unavailable: why the facts could not be fetched, or None.
|
||||
"""
|
||||
|
||||
merged_heads: dict[str, int] = field(default_factory=dict)
|
||||
open_heads: dict[str, int] = field(default_factory=dict)
|
||||
protected: set[str] = field(default_factory=set)
|
||||
unavailable: str | None = None
|
||||
|
||||
|
||||
def github_slugs(remote_urls: dict[str, str]) -> list[tuple[str, str]]:
|
||||
slugs: list[tuple[str, str]] = []
|
||||
for url in remote_urls.values():
|
||||
host, owner, name = parse_repo_from_git_url(url)
|
||||
if (
|
||||
host.lower() == GITHUB_HOST
|
||||
and owner
|
||||
and name
|
||||
and (owner, name) not in slugs
|
||||
):
|
||||
slugs.append((owner, name))
|
||||
return slugs
|
||||
|
||||
|
||||
def _pages(http: HttpClient, headers: dict[str, str], url: str) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = http.request_json(
|
||||
"GET", f"{url}&per_page={PAGE_SIZE}&page={page}", headers=headers
|
||||
)
|
||||
batch = json.loads(resp.text or "[]")
|
||||
items.extend(batch)
|
||||
if len(batch) < PAGE_SIZE:
|
||||
return items
|
||||
page += 1
|
||||
|
||||
|
||||
def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
|
||||
facts = ForgeFacts()
|
||||
slugs = github_slugs(remote_urls)
|
||||
if not slugs:
|
||||
return facts
|
||||
|
||||
try:
|
||||
token = (
|
||||
TokenResolver()
|
||||
.get_token(
|
||||
"github",
|
||||
GITHUB_HOST,
|
||||
options=ResolutionOptions(interactive=False, allow_prompt=False),
|
||||
)
|
||||
.token
|
||||
)
|
||||
except CredentialError as exc:
|
||||
facts.unavailable = f"no GitHub token ({exc})"
|
||||
return facts
|
||||
|
||||
http = HttpClient(timeout_s=30)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "pkgmgr",
|
||||
}
|
||||
for owner, name in slugs:
|
||||
base = f"{GITHUB_API}/repos/{owner}/{name}"
|
||||
try:
|
||||
for pr in _pages(http, headers, f"{base}/pulls?state=all"):
|
||||
if pr["merged_at"]:
|
||||
facts.merged_heads[pr["head"]["sha"]] = pr["number"]
|
||||
elif pr["state"] == "open":
|
||||
facts.open_heads[pr["head"]["ref"]] = pr["number"]
|
||||
for branch in _pages(http, headers, f"{base}/branches?protected=true"):
|
||||
facts.protected.add(branch["name"])
|
||||
except (HttpError, ValueError, KeyError, TypeError) as exc:
|
||||
facts.unavailable = f"GitHub API failed for {owner}/{name}: {exc}"
|
||||
return facts
|
||||
return facts
|
||||
@@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .cargo import update_cargo_version
|
||||
from .changelog_md import update_changelog
|
||||
from .debian import _get_debian_author, update_debian_changelog
|
||||
from .editor import _open_editor_for_changelog
|
||||
@@ -22,6 +23,7 @@ from .rpm_spec import update_spec_version
|
||||
__all__ = [
|
||||
"_get_debian_author",
|
||||
"_open_editor_for_changelog",
|
||||
"update_cargo_version",
|
||||
"update_changelog",
|
||||
"update_debian_changelog",
|
||||
"update_flake_version",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _write(path: str, content: str) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def update_cargo_version(
|
||||
repo_root: str, new_version: str, preview: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Set the root crate's version in Cargo.toml and its entry in Cargo.lock.
|
||||
|
||||
Args:
|
||||
repo_root: repository root that may hold Cargo.toml and Cargo.lock.
|
||||
new_version: release version without the leading "v".
|
||||
preview: only print what would change.
|
||||
|
||||
Returns:
|
||||
The paths that were (or in preview would be) changed.
|
||||
"""
|
||||
toml_path = os.path.join(repo_root, "Cargo.toml")
|
||||
if not os.path.exists(toml_path):
|
||||
print("[INFO] No Cargo.toml found. Skipping Cargo version update.")
|
||||
return []
|
||||
|
||||
toml = _read(toml_path)
|
||||
block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml)
|
||||
if not block:
|
||||
print("[INFO] Cargo.toml has no [package] section. Skipping.")
|
||||
return []
|
||||
|
||||
name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0))
|
||||
if not name:
|
||||
raise RuntimeError("Missing name key in [package] section of Cargo.toml")
|
||||
new_block, count = re.subn(
|
||||
r'(?m)^(\s*version\s*=\s*")[^"]+(")',
|
||||
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||
block.group(0),
|
||||
count=1,
|
||||
)
|
||||
if count == 0:
|
||||
raise RuntimeError("Missing version key in [package] section of Cargo.toml")
|
||||
changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]}
|
||||
|
||||
lock_path = os.path.join(repo_root, "Cargo.lock")
|
||||
if os.path.exists(lock_path):
|
||||
lock, count = re.subn(
|
||||
rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n'
|
||||
r'version = ")[^"]+("\n)(?!source = )',
|
||||
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||
_read(lock_path),
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError(
|
||||
f"Expected one local {name.group(1)!r} package in Cargo.lock, "
|
||||
f"found {count}"
|
||||
)
|
||||
changes[lock_path] = lock
|
||||
|
||||
for path, content in changes.items():
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would update {path} version to {new_version}")
|
||||
else:
|
||||
_write(path, content)
|
||||
print(f"Updated {path} version to {new_version}")
|
||||
return list(changes)
|
||||
@@ -3,6 +3,11 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
|
||||
def update_pyproject_version(
|
||||
pyproject_path: str, new_version: str, preview: bool = False
|
||||
@@ -18,6 +23,11 @@ def update_pyproject_version(
|
||||
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
||||
return
|
||||
|
||||
project = tomllib.loads(content).get("project")
|
||||
if project is None or "version" in project.get("dynamic", []):
|
||||
print("[INFO] pyproject.toml declares no static version, skipping.")
|
||||
return
|
||||
|
||||
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
|
||||
if not m:
|
||||
raise RuntimeError("Missing [project] section in pyproject.toml")
|
||||
|
||||
@@ -10,7 +10,7 @@ the side effects downstream are incomplete.
|
||||
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||
release can be re-pushed without touching code or recreating tags:
|
||||
|
||||
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||
* push the branch and the existing HEAD tag through `pkgmgr push`'s leases
|
||||
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||
|
||||
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||
@@ -23,7 +23,8 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.actions.repository.push import push_branch
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
@@ -50,7 +51,7 @@ def retry_release(
|
||||
tag = max(tags, key=SemVer.parse)
|
||||
print(f"Re-pushing existing tag: {tag}")
|
||||
|
||||
run(["push", "origin", branch, tag], preview=preview)
|
||||
push_branch(".", [], sign=True, refs=(tag,), preview=preview)
|
||||
|
||||
try:
|
||||
if is_highest_version_tag(tag):
|
||||
|
||||
@@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
@@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import (
|
||||
bump_patch,
|
||||
find_latest_version,
|
||||
)
|
||||
from pkgmgr.core.version.source import (
|
||||
read_cargo_version,
|
||||
read_changelog_version,
|
||||
read_debian_changelog_version,
|
||||
read_flake_version,
|
||||
read_pkgbuild_version,
|
||||
read_pyproject_version,
|
||||
read_spec_version,
|
||||
)
|
||||
|
||||
FILE_SOURCES = {
|
||||
"pyproject.toml": read_pyproject_version,
|
||||
"CHANGELOG.md": read_changelog_version,
|
||||
"flake.nix": read_flake_version,
|
||||
"PKGBUILD": read_pkgbuild_version,
|
||||
"RPM spec": read_spec_version,
|
||||
"debian/changelog": read_debian_changelog_version,
|
||||
"Cargo.toml": read_cargo_version,
|
||||
}
|
||||
_CORE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)")
|
||||
|
||||
|
||||
def determine_current_version() -> SemVer:
|
||||
def _file_versions(repo_dir: str) -> dict[str, SemVer]:
|
||||
versions: dict[str, SemVer] = {}
|
||||
for source, read in FILE_SOURCES.items():
|
||||
match = _CORE_VERSION.match(read(repo_dir) or "")
|
||||
if match:
|
||||
versions[source] = SemVer.parse(match.group(1))
|
||||
return versions
|
||||
|
||||
|
||||
def determine_current_version(repo_dir: str) -> SemVer:
|
||||
"""
|
||||
Determine the current semantic version from Git tags.
|
||||
Determine the highest version across the latest SemVer tag and every
|
||||
version file the release writes.
|
||||
|
||||
Behaviour:
|
||||
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
|
||||
- Otherwise, use the latest SemVer tag as current version.
|
||||
Args:
|
||||
repo_dir: repository root holding the version files.
|
||||
|
||||
Returns:
|
||||
The highest version found, or 0.0.0 when no source has one.
|
||||
"""
|
||||
tags = get_tags()
|
||||
if not tags:
|
||||
versions = _file_versions(repo_dir)
|
||||
latest = find_latest_version(get_tags() or [])
|
||||
if latest is not None:
|
||||
versions["tag"] = latest[1]
|
||||
if not versions:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
latest = find_latest_version(tags)
|
||||
if latest is None:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
_tag, ver = latest
|
||||
return ver
|
||||
highest = max(versions.values())
|
||||
if len(set(versions.values())) > 1:
|
||||
found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
|
||||
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
|
||||
return highest
|
||||
|
||||
|
||||
def bump_semver(current: SemVer, release_type: str) -> SemVer:
|
||||
|
||||
@@ -4,12 +4,14 @@ import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.actions.repository.push import push_branch, sign_unpushed
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.commands import add, commit, tag_annotated
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.repository.paths import resolve_repo_paths
|
||||
|
||||
from .files import (
|
||||
update_cargo_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_flake_version,
|
||||
@@ -48,7 +50,8 @@ def _release_impl(
|
||||
# Pull BEFORE making any modifications
|
||||
ensure_clean_and_synced(preview=preview)
|
||||
|
||||
current_ver = determine_current_version()
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
current_ver = determine_current_version(repo_root)
|
||||
new_ver = bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
@@ -58,7 +61,6 @@ def _release_impl(
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
paths = resolve_repo_paths(repo_root)
|
||||
|
||||
# --- Update versioned files ------------------------------------------------
|
||||
@@ -74,6 +76,8 @@ def _release_impl(
|
||||
|
||||
update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
|
||||
|
||||
cargo_files = update_cargo_version(repo_root, new_ver_str, preview=preview)
|
||||
|
||||
if paths.arch_pkgbuild:
|
||||
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
|
||||
else:
|
||||
@@ -124,6 +128,7 @@ def _release_impl(
|
||||
paths.arch_pkgbuild,
|
||||
paths.rpm_spec,
|
||||
paths.debian_changelog,
|
||||
*cargo_files,
|
||||
]
|
||||
existing_files = [
|
||||
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
|
||||
@@ -132,8 +137,9 @@ def _release_impl(
|
||||
if preview:
|
||||
add(existing_files, preview=True)
|
||||
commit(commit_msg, all=True, preview=True)
|
||||
sign_unpushed(".", preview=True)
|
||||
tag_annotated(new_tag, tag_msg, preview=True)
|
||||
run(["push", "origin", branch, new_tag], preview=True)
|
||||
push_branch(".", [], sign=True, refs=(new_tag,), preview=True)
|
||||
|
||||
if is_highest_version_tag(new_tag):
|
||||
update_latest_tag(new_tag, preview=True)
|
||||
@@ -153,10 +159,10 @@ def _release_impl(
|
||||
|
||||
add(existing_files, preview=False)
|
||||
commit(commit_msg, all=True, preview=False)
|
||||
sign_unpushed(".", preview=False)
|
||||
tag_annotated(new_tag, tag_msg, preview=False)
|
||||
|
||||
# Push branch and ONLY the newly created version tag in one command (no --tags)
|
||||
run(["push", "origin", branch, new_tag], preview=False)
|
||||
push_branch(".", [], sign=True, refs=(new_tag,), preview=False)
|
||||
|
||||
# Update 'latest' only if this is the highest version tag
|
||||
try:
|
||||
|
||||
@@ -1,39 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.repository._parallel import (
|
||||
RepoRef,
|
||||
resolve_repos,
|
||||
run_on_repos,
|
||||
)
|
||||
from pkgmgr.core.git.commands import GitPushArgsError, push_args
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
|
||||
Repository = dict[str, Any]
|
||||
|
||||
# Re-signing opens pinentry; parallel pushes must not ask for the passphrase at once.
|
||||
_SIGN_LOCK = threading.Lock()
|
||||
|
||||
def _push_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]:
|
||||
|
||||
class PushRefusedError(RuntimeError):
|
||||
"""Raised when a push would ship unsigned commits or overwrite a mirror."""
|
||||
|
||||
|
||||
def _try_git(repo_dir: str, *args: str) -> str | None:
|
||||
try:
|
||||
push_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return run(list(args), cwd=repo_dir)
|
||||
except GitRunError:
|
||||
return None
|
||||
|
||||
|
||||
def _push_target(repo_dir: str) -> tuple[str, str]:
|
||||
branch = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir)
|
||||
if branch == "HEAD":
|
||||
raise PushRefusedError(f"{repo_dir}: detached HEAD, nothing to push")
|
||||
remote = (
|
||||
_try_git(repo_dir, "config", "--get", f"branch.{branch}.pushRemote")
|
||||
or _try_git(repo_dir, "config", "--get", "remote.pushDefault")
|
||||
or _try_git(repo_dir, "config", "--get", f"branch.{branch}.remote")
|
||||
or "origin"
|
||||
)
|
||||
return branch, remote
|
||||
|
||||
|
||||
def _unpushed_signatures(repo_dir: str, remote: str) -> tuple[str, list[str]]:
|
||||
for ref in ("@{u}", f"{remote}/main", f"{remote}/master"):
|
||||
base = _try_git(repo_dir, "merge-base", "HEAD", ref)
|
||||
if base:
|
||||
return base, run(
|
||||
["log", "--format=%G?", f"{base}..HEAD"], cwd=repo_dir
|
||||
).split()
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: no upstream and no {remote}/main or {remote}/master to sign against"
|
||||
)
|
||||
|
||||
|
||||
def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
|
||||
"""
|
||||
GPG-sign every unpushed commit that is not signed yet, like git-sign-push.
|
||||
|
||||
Args:
|
||||
repo_dir: checkout whose current branch is signed.
|
||||
preview: print the fetch and rebase instead of running them.
|
||||
"""
|
||||
_branch, remote = _push_target(repo_dir)
|
||||
run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview)
|
||||
base, signatures = _unpushed_signatures(repo_dir, remote)
|
||||
unsigned = sum(1 for s in signatures if s != "G")
|
||||
if not unsigned:
|
||||
return
|
||||
if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir):
|
||||
raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot re-sign")
|
||||
|
||||
cmd = ["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base]
|
||||
print(f"[push] Signing {unsigned} of {len(signatures)} commit(s) in {repo_dir}")
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}")
|
||||
return
|
||||
with _SIGN_LOCK:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=repo_dir,
|
||||
env={**os.environ, "GIT_SEQUENCE_EDITOR": ":"},
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: re-sign rebase failed; resolve or `git rebase --abort`"
|
||||
)
|
||||
|
||||
|
||||
def _lease(
|
||||
repo_dir: str,
|
||||
url: str,
|
||||
branch_ref: str,
|
||||
tracking: str | None,
|
||||
keep: tuple[str, ...],
|
||||
preview: bool,
|
||||
) -> str:
|
||||
if tracking is not None:
|
||||
sha = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) or ""
|
||||
else:
|
||||
listed = run(["ls-remote", url, branch_ref], cwd=repo_dir).splitlines()
|
||||
sha = next(
|
||||
(ln.split()[0] for ln in listed if ln.split()[1:] == [branch_ref]), ""
|
||||
)
|
||||
if sha and not preview:
|
||||
run(["fetch", "--quiet", "--no-tags", url, branch_ref], cwd=repo_dir)
|
||||
if not sha or preview:
|
||||
return sha
|
||||
lost: set[str] | None = None
|
||||
for tip in keep:
|
||||
plus = {
|
||||
line[2:]
|
||||
for line in run(["cherry", tip, sha], cwd=repo_dir).splitlines()
|
||||
if line.startswith("+")
|
||||
}
|
||||
lost = plus if lost is None else lost & plus
|
||||
if lost:
|
||||
raise PushRefusedError(
|
||||
f"{url}: {len(lost)} commit(s) exist only there, not overwritten: "
|
||||
f"{' '.join(s[:10] for s in sorted(lost))}; "
|
||||
"pass --rewrite for a deliberate history rewrite"
|
||||
)
|
||||
return sha
|
||||
|
||||
|
||||
def push_branch(
|
||||
repo_dir: str,
|
||||
extra_args: list[str],
|
||||
*,
|
||||
sign: bool,
|
||||
refs: tuple[str, ...] = (),
|
||||
rewrite: bool = False,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Push the current branch to every pushurl of its push remote, each with its own lease.
|
||||
|
||||
The fetch URL is leased against the remote-tracking ref, every other
|
||||
pushurl against its own branch tip; each only after `git cherry` shows it
|
||||
holds no commit the new tip lacks. A mirror failure is reported after the
|
||||
others were pushed; a canonical failure stops before any mirror.
|
||||
|
||||
Args:
|
||||
repo_dir: checkout whose current branch is pushed.
|
||||
extra_args: flags appended to every `git push`.
|
||||
sign: refuse unsigned commits in the push range; False pushes them as they are.
|
||||
refs: extra refs (e.g. a release tag) pushed to every URL.
|
||||
rewrite: replace commits of the old canonical tip; the fetch URL skips
|
||||
the cherry check, mirrors only refuse commits the old tip never had.
|
||||
preview: print the pushes instead of running them.
|
||||
"""
|
||||
branch, remote = _push_target(repo_dir)
|
||||
if sign:
|
||||
_base, signatures = _unpushed_signatures(repo_dir, remote)
|
||||
if not preview and any(s != "G" for s in signatures):
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: {sum(s != 'G' for s in signatures)} unsigned commit(s) "
|
||||
"in the push range; run `pkgmgr push` to sign them"
|
||||
)
|
||||
|
||||
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
fetch_url = run(["remote", "get-url", remote], cwd=repo_dir)
|
||||
push_urls = run(
|
||||
["remote", "get-url", "--push", "--all", remote], cwd=repo_dir
|
||||
).splitlines()
|
||||
branch_ref = f"refs/heads/{branch}"
|
||||
tracking = f"refs/remotes/{remote}/{branch}"
|
||||
old_tip = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking)
|
||||
if fetch_url not in push_urls:
|
||||
print(
|
||||
f"[push] WARN {remote}: fetch URL is no pushurl; every URL is leased "
|
||||
"as a mirror, tracking ref and upstream stay untouched"
|
||||
)
|
||||
|
||||
failed: list[str] = []
|
||||
for url in sorted(push_urls, key=lambda u: u != fetch_url):
|
||||
if url == fetch_url:
|
||||
keep: tuple[str, ...] = () if rewrite else (head,)
|
||||
else:
|
||||
keep = (head, old_tip) if rewrite and old_tip else (head,)
|
||||
try:
|
||||
expect = _lease(
|
||||
repo_dir,
|
||||
url,
|
||||
branch_ref,
|
||||
tracking if url == fetch_url else None,
|
||||
keep,
|
||||
preview,
|
||||
)
|
||||
run(
|
||||
[
|
||||
"push",
|
||||
"--follow-tags",
|
||||
f"--force-with-lease={branch_ref}:{expect}",
|
||||
*extra_args,
|
||||
url,
|
||||
branch_ref,
|
||||
*refs,
|
||||
],
|
||||
cwd=repo_dir,
|
||||
preview=preview,
|
||||
)
|
||||
except (GitRunError, PushRefusedError) as exc:
|
||||
if url == fetch_url:
|
||||
raise
|
||||
print(f"[push] FAILED {url}\n{exc}")
|
||||
failed.append(url)
|
||||
continue
|
||||
print(f"[push] {url} <- {branch} {head[:10]}")
|
||||
if url == fetch_url and not preview:
|
||||
run(["update-ref", tracking, head], cwd=repo_dir)
|
||||
if not _try_git(repo_dir, "rev-parse", "--abbrev-ref", "@{u}"):
|
||||
run(["branch", f"--set-upstream-to={remote}/{branch}"], cwd=repo_dir)
|
||||
|
||||
if failed:
|
||||
raise PushRefusedError(f"{repo_dir}: push failed for {', '.join(failed)}")
|
||||
|
||||
|
||||
def _push_one(
|
||||
repo_dir: str, extra_args: list[str], sign: bool, rewrite: bool, preview: bool
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
if sign:
|
||||
sign_unpushed(repo_dir, preview=preview)
|
||||
push_branch(repo_dir, extra_args, sign=sign, rewrite=rewrite, preview=preview)
|
||||
return (True, "")
|
||||
except GitPushArgsError as exc:
|
||||
except (GitRunError, PushRefusedError) as exc:
|
||||
return (False, str(exc))
|
||||
|
||||
|
||||
def push_repo_dirs(
|
||||
repos: list[RepoRef],
|
||||
extra_args: list[str],
|
||||
preview: bool,
|
||||
sign: bool,
|
||||
jobs: int = 1,
|
||||
rewrite: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Sign and push each ``(identifier, repo_dir)``, optionally in parallel.
|
||||
"""
|
||||
run_on_repos(
|
||||
repos,
|
||||
lambda rd: _push_one(rd, extra_args, sign, rewrite, preview),
|
||||
jobs=jobs,
|
||||
op_name="push",
|
||||
)
|
||||
|
||||
|
||||
def push_in_parallel(
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: list[Repository],
|
||||
extra_args: list[str],
|
||||
preview: bool,
|
||||
sign: bool,
|
||||
jobs: int = 1,
|
||||
rewrite: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` for each repository, optionally in parallel.
|
||||
Sign and push each selected repository, optionally in parallel.
|
||||
"""
|
||||
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos)
|
||||
run_on_repos(
|
||||
repos,
|
||||
lambda rd: _push_one(rd, extra_args, preview),
|
||||
push_repo_dirs(
|
||||
resolve_repos(selected_repos, repositories_base_dir, all_repos),
|
||||
extra_args,
|
||||
preview,
|
||||
sign,
|
||||
jobs=jobs,
|
||||
op_name="push",
|
||||
rewrite=rewrite,
|
||||
)
|
||||
@@ -1,9 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch, drop_branch, open_branch
|
||||
from pkgmgr.actions.branch import (
|
||||
branch_gc,
|
||||
branch_purge,
|
||||
close_branch,
|
||||
drop_branch,
|
||||
open_branch,
|
||||
)
|
||||
from pkgmgr.actions.branch.gc import main_repo_root
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
def _gc_repo_roots(args, ctx: CLIContext) -> list[str]:
|
||||
if not args.all:
|
||||
return [main_repo_root(".")]
|
||||
dirs = [get_repo_dir(ctx.repositories_base_dir, r) for r in ctx.all_repositories]
|
||||
return [d for d in dirs if os.path.exists(os.path.join(d, ".git"))]
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
@@ -14,7 +31,30 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
- pkgmgr branch open [<name>] [--base <branch>]
|
||||
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
|
||||
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
|
||||
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||
"""
|
||||
if args.subcommand in ("gc", "purge"):
|
||||
try:
|
||||
roots = _gc_repo_roots(args, ctx)
|
||||
except GitBaseError as exc:
|
||||
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
options = {
|
||||
"include": args.branch,
|
||||
"exclude": args.exclude,
|
||||
"merged_into": args.merged_into,
|
||||
"sizes": args.sizes,
|
||||
"min_age": args.min_age,
|
||||
}
|
||||
if args.subcommand == "gc":
|
||||
code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
|
||||
else:
|
||||
code = branch_purge(roots, **options)
|
||||
sys.exit(code)
|
||||
|
||||
if args.subcommand == "open":
|
||||
open_branch(
|
||||
name=getattr(args, "name", None),
|
||||
|
||||
@@ -96,3 +96,69 @@ def add_branch_subparsers(
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt and drop the branch directly",
|
||||
)
|
||||
|
||||
branch_gc = branch_subparsers.add_parser(
|
||||
"gc",
|
||||
help=(
|
||||
"Remove merged and clean branches with their worktrees and compose "
|
||||
"stacks (dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
branch_gc.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Execute the plan instead of only printing it",
|
||||
)
|
||||
branch_gc.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the plan as JSON",
|
||||
)
|
||||
|
||||
branch_purge = branch_subparsers.add_parser(
|
||||
"purge",
|
||||
help="Delete unmerged or dirty branches after a confirmation per item",
|
||||
)
|
||||
|
||||
for sub in (branch_gc, branch_purge):
|
||||
sub.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Scan every pkgmgr-managed repository instead of the current one",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--branch",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Only this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Skip this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--merged-into",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="REF",
|
||||
help=(
|
||||
"Also count an item as merged when its commits are in REF (an "
|
||||
"integration line); REF is protected and a checkout at its tip "
|
||||
"counts as fresh; repeatable"
|
||||
),
|
||||
)
|
||||
sub.add_argument(
|
||||
"--sizes",
|
||||
action="store_true",
|
||||
help="Measure each worktree's disk usage (du) into the plan",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--min-age",
|
||||
type=float,
|
||||
metavar="HOURS",
|
||||
help="Skip items whose newest commit or worktree index is younger",
|
||||
)
|
||||
+39
-1
@@ -8,8 +8,9 @@ from typing import Any
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.actions.repository.push import push_in_parallel
|
||||
from pkgmgr.actions.repository.push import push_in_parallel, push_repo_dirs
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
|
||||
@@ -185,6 +186,25 @@ def register_proxy_commands(
|
||||
"(default: min(cpu_count, 8)). Use 1 for sequential."
|
||||
),
|
||||
)
|
||||
if subcommand == "push":
|
||||
parser.add_argument(
|
||||
"--rewrite",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Deliberate history rewrite: replace commits the remote "
|
||||
"already has (amend, squash, drop); leases still apply."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sign",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Push unsigned commits as they are instead of re-signing "
|
||||
"them; leases still apply."
|
||||
),
|
||||
)
|
||||
if subcommand == "clone":
|
||||
parser.add_argument(
|
||||
"--clone-mode",
|
||||
@@ -209,6 +229,22 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
||||
if args.command not in all_proxy_subcommands:
|
||||
return False
|
||||
|
||||
if args.command == "push" and not _proxy_has_explicit_selection(args):
|
||||
try:
|
||||
toplevel = run(["rev-parse", "--show-toplevel"])
|
||||
except GitRunError:
|
||||
print("[ERROR] Current directory is not inside a git checkout.")
|
||||
sys.exit(1)
|
||||
push_repo_dirs(
|
||||
[(toplevel, toplevel)],
|
||||
args.extra_args,
|
||||
args.preview,
|
||||
not args.no_sign,
|
||||
jobs=args.jobs,
|
||||
rewrite=args.rewrite,
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
# Default semantics: without explicit selection → repo of current folder.
|
||||
if _proxy_has_explicit_selection(args):
|
||||
selected = get_selected_repos(args, ctx.all_repositories)
|
||||
@@ -251,7 +287,9 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
||||
ctx.all_repositories,
|
||||
args.extra_args,
|
||||
args.preview,
|
||||
not args.no_sign,
|
||||
jobs=args.jobs,
|
||||
rewrite=args.rewrite,
|
||||
)
|
||||
else:
|
||||
exec_proxy_command(
|
||||
|
||||
@@ -19,7 +19,6 @@ from .pull import GitPullError, pull
|
||||
from .pull_args import GitPullArgsError, pull_args
|
||||
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
|
||||
from .push import GitPushError, push
|
||||
from .push_args import GitPushArgsError, push_args
|
||||
from .push_upstream import GitPushUpstreamError, push_upstream
|
||||
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
||||
from .tag_annotated import GitTagAnnotatedError, tag_annotated
|
||||
@@ -43,7 +42,6 @@ __all__ = [
|
||||
"GitPullArgsError",
|
||||
"GitPullError",
|
||||
"GitPullFfOnlyError",
|
||||
"GitPushArgsError",
|
||||
"GitPushError",
|
||||
"GitPushUpstreamError",
|
||||
"GitSetRemoteUrlError",
|
||||
@@ -67,7 +65,6 @@ __all__ = [
|
||||
"pull_args",
|
||||
"pull_ff_only",
|
||||
"push",
|
||||
"push_args",
|
||||
"push_upstream",
|
||||
"set_remote_url",
|
||||
"tag_annotated",
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitCommandError, GitRunError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPushArgsError(GitCommandError):
|
||||
"""Raised when `git push` with arbitrary args fails."""
|
||||
|
||||
|
||||
def push_args(
|
||||
args: list[str] | None = None,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` with caller-provided arguments.
|
||||
|
||||
Examples:
|
||||
[] -> git push
|
||||
["--force"] -> git push --force
|
||||
["origin", "main"] -> git push origin main
|
||||
["-u", "origin", "feature"] -> git push -u origin feature
|
||||
"""
|
||||
extra = args or []
|
||||
try:
|
||||
run(["push", *extra], cwd=cwd, preview=preview)
|
||||
except GitRunError as exc:
|
||||
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
|
||||
raise GitPushArgsError(
|
||||
(
|
||||
f"Failed to run `git push` with args={extra!r} "
|
||||
f"in cwd={cwd!r}.\n{details}"
|
||||
).rstrip(),
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
@@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_cargo_version(repo_dir: str) -> str | None:
|
||||
"""
|
||||
Read [package].version from Cargo.toml in repo_dir, if present.
|
||||
"""
|
||||
path = os.path.join(repo_dir, "Cargo.toml")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
version = (tomllib.load(f).get("package") or {}).get("version")
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
return version.strip() if isinstance(version, str) and version.strip() else None
|
||||
|
||||
|
||||
def read_changelog_version(repo_dir: str) -> str | None:
|
||||
"""
|
||||
Read the newest release from CHANGELOG.md ("## [X.Y.Z] - date").
|
||||
"""
|
||||
path = os.path.join(repo_dir, "CHANGELOG.md")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
match = re.search(r"(?m)^##\s+\[(v?\d+\.\d+\.\d+)\]", f.read())
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
return match.group(1).strip() if match else None
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.branch import gc
|
||||
from pkgmgr.actions.branch.gc_forge import ForgeFacts
|
||||
from pkgmgr.cli.commands.branch import handle_branch
|
||||
from pkgmgr.cli.parser import create_parser
|
||||
|
||||
GIT_ENV = {
|
||||
**os.environ,
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@example.org",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@example.org",
|
||||
}
|
||||
|
||||
|
||||
def git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=cwd,
|
||||
env=GIT_ENV,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def docker_available() -> bool:
|
||||
try:
|
||||
subprocess.run(["docker", "info"], check=True, capture_output=True)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class TestBranchGc(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.origin = os.path.join(self.root, "origin.git")
|
||||
self.repo = os.path.join(self.root, "repo")
|
||||
git(self.root, "init", "--bare", "-b", "main", self.origin)
|
||||
git(self.root, "init", "-b", "main", self.repo)
|
||||
git(self.repo, "remote", "add", "origin", self.origin)
|
||||
self._commit("init")
|
||||
git(self.repo, "push", "-u", "origin", "main")
|
||||
|
||||
self._branch_with_commit("merged")
|
||||
git(self.repo, "merge", "--no-ff", "-m", "merge merged", "merged")
|
||||
git(self.repo, "push", "origin", "main")
|
||||
git(self.repo, "branch", "fresh")
|
||||
git(self.repo, "push", "origin", "fresh")
|
||||
self._branch_with_commit("wip")
|
||||
self._branch_with_commit("squashed")
|
||||
self._branch_with_commit("locked")
|
||||
git(self.repo, "merge", "--no-ff", "-m", "merge locked", "locked")
|
||||
git(self.repo, "push", "origin", "main")
|
||||
git(self.repo, "remote", "set-head", "origin", "main")
|
||||
|
||||
self.merged_wt = os.path.join(self.root, "wt-merged")
|
||||
git(self.repo, "worktree", "add", self.merged_wt, "merged")
|
||||
self.dirty_wt = os.path.join(self.root, "wt-dirty")
|
||||
git(self.repo, "worktree", "add", "-b", "dirty", self.dirty_wt, "merged")
|
||||
with open(os.path.join(self.dirty_wt, "scratch.txt"), "w") as fh:
|
||||
fh.write("x")
|
||||
self.locked_wt = os.path.join(self.root, "wt-locked")
|
||||
git(self.repo, "worktree", "add", self.locked_wt, "locked")
|
||||
|
||||
self.squashed_sha = git(self.repo, "rev-parse", "squashed")
|
||||
self.forge = ForgeFacts(
|
||||
merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _commit(self, name: str) -> None:
|
||||
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
|
||||
fh.write(name)
|
||||
git(self.repo, "add", f"{name}.txt")
|
||||
git(self.repo, "commit", "-m", name)
|
||||
|
||||
def _branch_with_commit(self, name: str) -> None:
|
||||
git(self.repo, "checkout", "-b", name, "main")
|
||||
self._commit(name)
|
||||
git(self.repo, "push", "origin", name)
|
||||
git(self.repo, "checkout", "main")
|
||||
|
||||
def _lock(self, reason: str) -> None:
|
||||
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt)
|
||||
|
||||
def _plan(
|
||||
self,
|
||||
host_ns: bool,
|
||||
cwds: dict[int, str],
|
||||
merged_into: list[str] | None = None,
|
||||
sizes: bool = False,
|
||||
min_age: float | None = None,
|
||||
) -> gc.Repo:
|
||||
with (
|
||||
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
||||
patch.object(gc, "_process_cwds", return_value=cwds),
|
||||
):
|
||||
return gc.plan_repo(
|
||||
self.repo,
|
||||
[],
|
||||
host_ns,
|
||||
merged_into=merged_into or [],
|
||||
sizes=sizes,
|
||||
min_age=min_age,
|
||||
)
|
||||
|
||||
def _integration_line(self) -> str:
|
||||
git(self.repo, "checkout", "-b", "line", "main")
|
||||
self._commit("line-base")
|
||||
git(self.repo, "checkout", "-b", "feat", "line")
|
||||
self._commit("feat")
|
||||
git(self.repo, "checkout", "line")
|
||||
git(self.repo, "merge", "--no-ff", "-m", "merge feat", "feat")
|
||||
git(self.repo, "push", "origin", "line", "feat")
|
||||
git(self.repo, "branch", "start", "line")
|
||||
git(self.repo, "checkout", "main")
|
||||
feat_wt = os.path.join(self.root, "wt-feat")
|
||||
git(self.repo, "worktree", "add", feat_wt, "feat")
|
||||
return feat_wt
|
||||
|
||||
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
|
||||
return {item.branch: item for item in plan.items if item.branch}
|
||||
|
||||
def test_classifies_every_branch(self) -> None:
|
||||
items = self._items(self._plan(host_ns=True, cwds={}))
|
||||
|
||||
self.assertEqual(items["main"].state, "protected")
|
||||
self.assertEqual(items["merged"].state, "merged-clean")
|
||||
self.assertEqual(items["merged"].merged_by, "ancestor")
|
||||
self.assertEqual(
|
||||
items["merged"].actions,
|
||||
["worktree", "local-branch", "remote-branch:origin"],
|
||||
)
|
||||
self.assertTrue(items["merged"].eligible)
|
||||
self.assertEqual(items["squashed"].merged_by, "pr#7")
|
||||
self.assertTrue(items["squashed"].eligible)
|
||||
self.assertEqual(items["fresh"].state, "unmerged")
|
||||
self.assertEqual(items["fresh"].reason, "no own commits")
|
||||
self.assertEqual(items["wip"].state, "unmerged")
|
||||
self.assertEqual(items["wip"].skip, "open-pr #3")
|
||||
self.assertEqual(items["dirty"].state, "dirty")
|
||||
|
||||
def test_merged_into_counts_commits_in_an_integration_line(self) -> None:
|
||||
self._integration_line()
|
||||
before = self._items(self._plan(host_ns=True, cwds={}))
|
||||
self.assertEqual(before["feat"].state, "unmerged")
|
||||
self.assertIsNone(before["feat"].merged_into)
|
||||
|
||||
for ref in ("line", "origin/line"):
|
||||
with self.subTest(ref=ref):
|
||||
items = self._items(
|
||||
self._plan(host_ns=True, cwds={}, merged_into=[ref])
|
||||
)
|
||||
self.assertEqual(items["feat"].state, "merged-clean")
|
||||
self.assertEqual(items["feat"].merged_by, f"line:{ref}")
|
||||
self.assertEqual(items["feat"].merged_into, ref)
|
||||
self.assertTrue(items["feat"].eligible)
|
||||
self.assertEqual(items["line"].state, "protected")
|
||||
self.assertEqual(items["start"].state, "unmerged")
|
||||
self.assertEqual(items["merged"].merged_into, "origin/main")
|
||||
|
||||
def test_unknown_merged_into_ref_is_a_warning(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={}, merged_into=["no-such-line"])
|
||||
|
||||
self.assertIn("merged-into ref no-such-line not found", plan.warnings)
|
||||
self.assertEqual(self._items(plan)["merged"].state, "merged-clean")
|
||||
|
||||
def test_sizes_and_min_age(self) -> None:
|
||||
items = self._items(self._plan(host_ns=True, cwds={}, sizes=True))
|
||||
self.assertGreater(items["merged"].size_bytes, 0)
|
||||
self.assertIsNone(items["squashed"].size_bytes)
|
||||
self.assertIn("size ", gc._line(items["merged"]))
|
||||
|
||||
young = self._items(self._plan(host_ns=True, cwds={}, min_age=1000))
|
||||
self.assertEqual(young["merged"].skip, "younger than 1000 h")
|
||||
self.assertFalse(young["merged"].eligible)
|
||||
old = self._items(self._plan(host_ns=True, cwds={}, min_age=0))
|
||||
self.assertIsNone(old["merged"].skip)
|
||||
|
||||
def test_busy_worktree_is_skipped(self) -> None:
|
||||
cwds = {4242: os.path.join(self.merged_wt, "sub")}
|
||||
items = self._items(self._plan(host_ns=True, cwds=cwds))
|
||||
|
||||
self.assertTrue(items["merged"].skip.startswith("process: pid 4242"))
|
||||
self.assertFalse(items["merged"].eligible)
|
||||
|
||||
def test_lock_with_live_pid_is_skipped(self) -> None:
|
||||
self._lock(f"claude agent x (pid {os.getpid()} on host)")
|
||||
items = self._items(self._plan(host_ns=True, cwds={}))
|
||||
|
||||
self.assertEqual(items["locked"].skip, f"lock: pid {os.getpid()} alive")
|
||||
|
||||
def test_lock_with_dead_pid_is_overridden(self) -> None:
|
||||
self._lock("claude agent x (pid 999999999 on host)")
|
||||
items = self._items(self._plan(host_ns=True, cwds={}))
|
||||
|
||||
self.assertIsNone(items["locked"].skip)
|
||||
self.assertTrue(items["locked"].eligible)
|
||||
|
||||
def test_lock_outside_host_namespace_is_unknown(self) -> None:
|
||||
self._lock("claude agent x (pid 999999999 on host)")
|
||||
items = self._items(self._plan(host_ns=False, cwds={}))
|
||||
|
||||
self.assertEqual(items["locked"].skip, "lock: liveness unknown")
|
||||
|
||||
def test_lock_without_pid_is_respected(self) -> None:
|
||||
self._lock("manual hold")
|
||||
items = self._items(self._plan(host_ns=True, cwds={}))
|
||||
|
||||
self.assertEqual(items["locked"].skip, "lock: manual hold")
|
||||
|
||||
def test_forge_unavailable_skips_everything(self) -> None:
|
||||
self.forge = ForgeFacts(unavailable="no GitHub token")
|
||||
items = self._items(self._plan(host_ns=True, cwds={}))
|
||||
|
||||
self.assertEqual(items["merged"].skip, "forge unavailable")
|
||||
self.assertIsNone(items["squashed"].merged_by)
|
||||
|
||||
def test_stale_metadata_is_listed_not_pruned(self) -> None:
|
||||
stub = os.path.join(self.repo, ".git", "worktrees", "stub")
|
||||
os.makedirs(stub)
|
||||
open(os.path.join(stub, "commondir"), "w").close()
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
|
||||
stale = [i for i in plan.items if i.state == "stale"]
|
||||
self.assertEqual([i.worktree for i in stale], [stub])
|
||||
self.assertTrue(os.path.isdir(stub))
|
||||
|
||||
def test_execute_removes_worktree_and_branches(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertFalse(os.path.exists(self.merged_wt))
|
||||
self.assertFalse(os.path.exists(meta))
|
||||
self.assertEqual(git(self.repo, "branch", "--list", "merged"), "")
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "")
|
||||
|
||||
@unittest.skipUnless(docker_available(), "docker is not available")
|
||||
def test_execute_removes_foreign_owned_worktree_via_container(self) -> None:
|
||||
with open(os.path.join(self.repo, ".git", "info", "exclude"), "a") as fh:
|
||||
fh.write("node_modules/\n")
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
f"{self.merged_wt}:/w",
|
||||
gc.CLEANUP_IMAGE,
|
||||
"sh",
|
||||
"-c",
|
||||
(
|
||||
"mkdir -p /w/node_modules/pkg && touch /w/node_modules/pkg/index.js"
|
||||
" && chown -R 65534:65534 /w/node_modules"
|
||||
),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertTrue(gc._foreign_owned(self.merged_wt))
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertFalse(os.path.lexists(self.merged_wt))
|
||||
self.assertFalse(os.path.exists(meta))
|
||||
self.assertEqual(git(self.repo, "branch", "--list", "merged"), "")
|
||||
|
||||
def test_metadata_kept_when_checkout_survives_removal(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
|
||||
|
||||
with (
|
||||
patch.object(gc, "_process_cwds", return_value={}),
|
||||
patch.object(gc, "_foreign_owned", return_value=True),
|
||||
patch.object(gc, "_docker", return_value="") as docker,
|
||||
):
|
||||
gc.execute(plan, item)
|
||||
|
||||
docker.assert_called_once_with(
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
f"{self.root}:/w",
|
||||
gc.CLEANUP_IMAGE,
|
||||
"rm",
|
||||
"-rf",
|
||||
"/w/wt-merged",
|
||||
]
|
||||
)
|
||||
self.assertTrue(item.result.startswith("failed: worktree"))
|
||||
self.assertTrue(os.path.isdir(self.merged_wt))
|
||||
self.assertTrue(os.path.isdir(meta))
|
||||
self.assertNotEqual(git(self.repo, "branch", "--list", "merged"), "")
|
||||
|
||||
def test_execute_skips_item_changed_since_plan(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
with open(os.path.join(self.merged_wt, "late.txt"), "w") as fh:
|
||||
fh.write("late")
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertTrue(item.result.startswith("skipped: changed since plan"))
|
||||
self.assertTrue(os.path.isdir(self.merged_wt))
|
||||
|
||||
def test_branch_gc_json_and_refusal(self) -> None:
|
||||
out = io.StringIO()
|
||||
with (
|
||||
patch.object(gc, "host_pid_namespace", return_value=True),
|
||||
patch.object(gc, "list_compose_containers", return_value=[]),
|
||||
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
||||
patch.object(gc, "_process_cwds", return_value={}),
|
||||
redirect_stdout(out),
|
||||
):
|
||||
code = gc.branch_gc(
|
||||
[self.repo],
|
||||
apply=False,
|
||||
as_json=True,
|
||||
include=["merged"],
|
||||
exclude=[],
|
||||
merged_into=["origin/main"],
|
||||
sizes=True,
|
||||
min_age=None,
|
||||
)
|
||||
data = json.loads(out.getvalue())
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(data["repos"][0]["merged_into"], ["origin/main"])
|
||||
items = data["repos"][0]["items"]
|
||||
self.assertEqual([i["branch"] for i in items], ["merged"])
|
||||
self.assertEqual(items[0]["merged_into"], "origin/main")
|
||||
self.assertGreater(items[0]["size_bytes"], 0)
|
||||
with patch.object(gc, "host_pid_namespace", return_value=False):
|
||||
self.assertEqual(
|
||||
gc.branch_gc(
|
||||
[self.repo],
|
||||
apply=True,
|
||||
as_json=False,
|
||||
include=[],
|
||||
exclude=[],
|
||||
merged_into=[],
|
||||
sizes=False,
|
||||
min_age=None,
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_cli_forwards_gc_flags(self) -> None:
|
||||
args = create_parser("test").parse_args(
|
||||
[
|
||||
"branch",
|
||||
"gc",
|
||||
"--apply",
|
||||
"--json",
|
||||
"--branch",
|
||||
"a",
|
||||
"--branch",
|
||||
"b",
|
||||
"--exclude",
|
||||
"c",
|
||||
"--merged-into",
|
||||
"origin/line",
|
||||
"--merged-into",
|
||||
"fork/line",
|
||||
"--sizes",
|
||||
"--min-age",
|
||||
"24",
|
||||
]
|
||||
)
|
||||
with (
|
||||
patch("pkgmgr.cli.commands.branch.main_repo_root", return_value=self.repo),
|
||||
patch("pkgmgr.cli.commands.branch.branch_gc", return_value=1) as run_gc,
|
||||
self.assertRaises(SystemExit) as exit_ctx,
|
||||
):
|
||||
handle_branch(args, ctx=None)
|
||||
|
||||
self.assertEqual(exit_ctx.exception.code, 1)
|
||||
run_gc.assert_called_once_with(
|
||||
[self.repo],
|
||||
apply=True,
|
||||
as_json=True,
|
||||
include=["a", "b"],
|
||||
exclude=["c"],
|
||||
merged_into=["origin/line", "fork/line"],
|
||||
sizes=True,
|
||||
min_age=24.0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE = "--build-arg BASE_IMAGE=ghcr.io/kevinveenbirkenbach/base-{distro}:latest ."
|
||||
|
||||
|
||||
class TestBuildImagePublish(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
tmp = Path(self._tmp.name)
|
||||
self.log = tmp / "docker.log"
|
||||
fake = tmp / "docker"
|
||||
fake.write_text('#!/usr/bin/env bash\necho "$*" >> "$FAKE_DOCKER_LOG"\n')
|
||||
fake.chmod(0o755)
|
||||
self.env = {
|
||||
k: v for k, v in os.environ.items() if not k.startswith("BASE_IMAGES_")
|
||||
}
|
||||
self.env["PATH"] = f"{tmp}{os.pathsep}{os.environ['PATH']}"
|
||||
self.env["FAKE_DOCKER_LOG"] = str(self.log)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _run(self, script: str, distro: str, *args: str, **env: str):
|
||||
result = subprocess.run(
|
||||
["bash", f"scripts/build/{script}", *args],
|
||||
check=False,
|
||||
cwd=REPO_ROOT,
|
||||
env={**self.env, "PKGMGR_DISTRO": distro, **env},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
calls = self.log.read_text().splitlines() if self.log.exists() else []
|
||||
return result, calls
|
||||
|
||||
def _publish(self, distro: str, *args: str):
|
||||
return self._run(
|
||||
"image.sh",
|
||||
distro,
|
||||
"--publish",
|
||||
"--registry",
|
||||
"ghcr.io",
|
||||
"--owner",
|
||||
"o",
|
||||
"--version",
|
||||
"2.1.2",
|
||||
*args,
|
||||
)
|
||||
|
||||
def test_platform_pushes_only_the_arch_tag(self) -> None:
|
||||
result, calls = self._publish("debian", "--platform", "linux/arm64")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
(
|
||||
"buildx build --push -t ghcr.io/o/pkgmgr-debian:2.1.2-arm64 "
|
||||
"--platform linux/arm64 "
|
||||
)
|
||||
+ BASE.format(distro="debian")
|
||||
],
|
||||
)
|
||||
|
||||
def test_merge_joins_arch_tags_under_release_and_alias_tags(self) -> None:
|
||||
result, calls = self._publish("arch", "--stable", "true", "--merge")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
tags = " ".join(
|
||||
f"-t ghcr.io/o/{image}:{tag}"
|
||||
for image in ("pkgmgr-arch", "pkgmgr")
|
||||
for tag in ("latest", "2.1.2", "stable")
|
||||
)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
(
|
||||
f"buildx imagetools create {tags} "
|
||||
"ghcr.io/o/pkgmgr-arch:2.1.2-amd64 ghcr.io/o/pkgmgr-arch:2.1.2-arm64"
|
||||
),
|
||||
"buildx imagetools inspect ghcr.io/o/pkgmgr-arch:2.1.2",
|
||||
],
|
||||
)
|
||||
|
||||
def test_unknown_platform_and_merge_without_publish_are_refused(self) -> None:
|
||||
unknown, _ = self._publish("debian", "--platform", "linux/s390x")
|
||||
no_publish, calls = self._run("image.sh", "debian", "--merge")
|
||||
|
||||
self.assertEqual(unknown.returncode, 2)
|
||||
self.assertEqual(no_publish.returncode, 2)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_publish_builds_each_platform_then_merges(self) -> None:
|
||||
result, calls = self._run(
|
||||
"publish.sh", "fedora", OWNER="o", VERSION="2.1.2", DISTROS="fedora"
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
[c.split(" -t ")[0] for c in calls],
|
||||
["buildx build --push", "buildx build --push", "buildx imagetools create"]
|
||||
+ ["buildx imagetools inspect ghcr.io/o/pkgmgr-fedora:2.1.2"],
|
||||
)
|
||||
self.assertIn("--platform linux/amd64", calls[0])
|
||||
self.assertIn("--platform linux/arm64", calls[1])
|
||||
self.assertNotIn(":stable", calls[2])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.retry import retry_release
|
||||
from pkgmgr.actions.repository.push import (
|
||||
PushRefusedError,
|
||||
push_branch,
|
||||
sign_unpushed,
|
||||
)
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.cli.parser import create_parser
|
||||
from pkgmgr.cli.proxy import maybe_handle_proxy
|
||||
|
||||
|
||||
def git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh-keygen"), "ssh-keygen signs the commits")
|
||||
class TestPushMirrorLeases(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
root = Path(os.path.realpath(self._tmp.name))
|
||||
self.root = str(root)
|
||||
key = root / "key"
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)],
|
||||
check=True,
|
||||
)
|
||||
(root / "allowed").write_text(f"t@example.org {(root / 'key.pub').read_text()}")
|
||||
(root / "gitconfig").write_text(
|
||||
"[user]\n\tname = t\n\temail = t@example.org\n"
|
||||
f"\tsigningkey = {key}\n"
|
||||
"[gpg]\n\tformat = ssh\n"
|
||||
f'[gpg "ssh"]\n\tallowedSignersFile = {root / "allowed"}\n'
|
||||
"[commit]\n\tgpgsign = false\n"
|
||||
"[init]\n\tdefaultBranch = main\n"
|
||||
)
|
||||
env = patch.dict(
|
||||
os.environ,
|
||||
{"GIT_CONFIG_GLOBAL": str(root / "gitconfig"), "GIT_CONFIG_NOSYSTEM": "1"},
|
||||
)
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
|
||||
self.canonical = str(root / "canonical.git")
|
||||
self.mirror = str(root / "mirror.git")
|
||||
self.repo = str(root / "repo")
|
||||
git(self.root, "init", "-q", "--bare", self.canonical)
|
||||
git(self.root, "init", "-q", "--bare", self.mirror)
|
||||
git(self.root, "init", "-q", self.repo)
|
||||
git(self.repo, "remote", "add", "origin", self.canonical)
|
||||
git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.canonical)
|
||||
git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.mirror)
|
||||
self.init = self._commit("init")
|
||||
git(self.repo, "push", "-q", self.canonical, "main")
|
||||
git(self.repo, "push", "-q", self.mirror, "main")
|
||||
git(self.repo, "fetch", "-q", "origin")
|
||||
git(self.repo, "branch", "-q", "--set-upstream-to=origin/main")
|
||||
|
||||
def _commit(self, name: str, cwd: str | None = None, *flags: str) -> str:
|
||||
cwd = cwd or self.repo
|
||||
Path(cwd, f"{name}.txt").write_text(name)
|
||||
git(cwd, "add", f"{name}.txt")
|
||||
git(cwd, "commit", "-q", *flags, "-m", name)
|
||||
return git(cwd, "rev-parse", "HEAD")
|
||||
|
||||
def _push(self, rewrite: bool = False) -> str:
|
||||
with redirect_stdout(io.StringIO()) as out:
|
||||
sign_unpushed(self.repo)
|
||||
push_branch(self.repo, [], sign=True, rewrite=rewrite)
|
||||
return out.getvalue()
|
||||
|
||||
def _cli_push(self, cwd: str, *argv: str) -> None:
|
||||
ctx = CLIContext(
|
||||
config_merged={},
|
||||
repositories_base_dir=os.path.join(self.root, "base"),
|
||||
all_repositories=[],
|
||||
binaries_dir=os.path.join(self.root, "bin"),
|
||||
user_config_path=os.path.join(self.root, "config.yaml"),
|
||||
)
|
||||
args = create_parser("pkgmgr").parse_args(["push", "-j", "1", *argv])
|
||||
prev = os.getcwd()
|
||||
os.chdir(cwd)
|
||||
self.addCleanup(os.chdir, prev)
|
||||
|
||||
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as cm:
|
||||
maybe_handle_proxy(args, ctx)
|
||||
|
||||
self.assertEqual(cm.exception.code, 0)
|
||||
|
||||
def _tip(self, bare: str, ref: str = "refs/heads/main") -> str:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "--verify", "--quiet", ref],
|
||||
cwd=bare,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
def _assert_shipped_signed(self) -> None:
|
||||
head = git(self.repo, "rev-parse", "HEAD")
|
||||
self.assertEqual(self._tip(self.canonical), head)
|
||||
self.assertEqual(self._tip(self.mirror), head)
|
||||
signatures = git(self.repo, "log", "--format=%G?", f"{self.init}..HEAD")
|
||||
self.assertEqual(set(signatures.split()), {"G"})
|
||||
|
||||
def test_mirror_equal_gets_the_signed_commit(self) -> None:
|
||||
self._commit("feature")
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
|
||||
def test_mirror_behind_is_fast_forwarded(self) -> None:
|
||||
self._commit("first", None, "-S")
|
||||
git(self.repo, "push", "-q", self.canonical, "main")
|
||||
git(self.repo, "fetch", "-q", "origin")
|
||||
self._commit("second")
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
|
||||
def test_mirror_on_older_rewrite_is_replaced_after_resign(self) -> None:
|
||||
unsigned = self._commit("feature")
|
||||
git(self.repo, "push", "-q", self.mirror, "main")
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
self.assertNotEqual(self._tip(self.mirror), unsigned)
|
||||
|
||||
def test_mirror_only_commit_is_refused_and_kept(self) -> None:
|
||||
other = os.path.join(self.root, "other")
|
||||
git(self.root, "clone", "-q", self.mirror, other)
|
||||
foreign = self._commit("foreign", other)
|
||||
git(other, "push", "-q", "origin", "main")
|
||||
self._commit("feature")
|
||||
|
||||
with self.assertRaises(PushRefusedError) as cm:
|
||||
self._push()
|
||||
with self.assertRaises(PushRefusedError):
|
||||
self._push(rewrite=True)
|
||||
|
||||
self.assertIn(self.mirror, str(cm.exception))
|
||||
self.assertEqual(self._tip(self.mirror), foreign)
|
||||
self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "HEAD"))
|
||||
|
||||
def test_content_rewrite_needs_the_rewrite_flag(self) -> None:
|
||||
self._commit("feature")
|
||||
self._push()
|
||||
shipped = git(self.repo, "rev-parse", "HEAD")
|
||||
Path(self.repo, "feature.txt").write_text("changed")
|
||||
git(self.repo, "commit", "-q", "-a", "--amend", "--no-edit")
|
||||
|
||||
with self.assertRaises(PushRefusedError) as cm:
|
||||
self._push()
|
||||
|
||||
self.assertIn(self.canonical, str(cm.exception))
|
||||
self.assertEqual(self._tip(self.canonical), shipped)
|
||||
self.assertEqual(self._tip(self.mirror), shipped)
|
||||
|
||||
self._push(rewrite=True)
|
||||
|
||||
self._assert_shipped_signed()
|
||||
self.assertNotEqual(self._tip(self.mirror), shipped)
|
||||
|
||||
def test_worktree_outside_base_dir_is_pushed(self) -> None:
|
||||
worktree = os.path.join(self.root, "wt")
|
||||
git(self.repo, "worktree", "add", "-q", "-b", "feature", worktree)
|
||||
head_before = self._commit("feature", worktree)
|
||||
|
||||
self._cli_push(worktree)
|
||||
|
||||
head = git(worktree, "rev-parse", "HEAD")
|
||||
self.assertNotEqual(head, head_before)
|
||||
self.assertEqual(self._tip(self.canonical, "refs/heads/feature"), head)
|
||||
self.assertEqual(self._tip(self.mirror, "refs/heads/feature"), head)
|
||||
self.assertEqual(
|
||||
git(worktree, "rev-parse", "--abbrev-ref", "@{u}"), "origin/feature"
|
||||
)
|
||||
|
||||
def test_no_sign_pushes_unsigned_commits_to_every_url(self) -> None:
|
||||
unsigned = self._commit("feature")
|
||||
|
||||
self._cli_push(self.repo, "--no-sign")
|
||||
|
||||
self.assertEqual(git(self.repo, "rev-parse", "HEAD"), unsigned)
|
||||
self.assertEqual(self._tip(self.canonical), unsigned)
|
||||
self.assertEqual(self._tip(self.mirror), unsigned)
|
||||
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?"), "N")
|
||||
|
||||
def test_release_retry_refuses_unsigned_range(self) -> None:
|
||||
self._commit("release")
|
||||
git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0")
|
||||
cwd = os.getcwd()
|
||||
os.chdir(self.repo)
|
||||
self.addCleanup(os.chdir, cwd)
|
||||
|
||||
with redirect_stdout(io.StringIO()), self.assertRaises(PushRefusedError):
|
||||
retry_release(pyproject_path=os.path.join(self.repo, "pyproject.toml"))
|
||||
|
||||
self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "@{u}"))
|
||||
self.assertEqual(self._tip(self.canonical, "refs/tags/v1.0.0"), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pkgmgr.actions.release.files import update_cargo_version
|
||||
|
||||
CARGO_TOML = """[package]
|
||||
name = "security-check"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.0" }
|
||||
"""
|
||||
|
||||
CARGO_LOCK = """version = 4
|
||||
|
||||
[[package]]
|
||||
name = "security-check"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc"
|
||||
|
||||
[[package]]
|
||||
name = "security-check"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
"""
|
||||
|
||||
|
||||
class TestUpdateCargoVersion(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _write(self, name: str, content: str) -> str:
|
||||
path = os.path.join(self.root, name)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
def _read(self, name: str) -> str:
|
||||
with open(os.path.join(self.root, name), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def test_bumps_package_and_local_lock_entry_only(self) -> None:
|
||||
toml = self._write("Cargo.toml", CARGO_TOML)
|
||||
lock = self._write("Cargo.lock", CARGO_LOCK)
|
||||
|
||||
changed = update_cargo_version(self.root, "0.4.0")
|
||||
|
||||
self.assertEqual(changed, [toml, lock])
|
||||
self.assertEqual(
|
||||
self._read("Cargo.toml"),
|
||||
CARGO_TOML.replace('version = "0.1.0"', 'version = "0.4.0"'),
|
||||
)
|
||||
self.assertEqual(
|
||||
self._read("Cargo.lock"),
|
||||
CARGO_LOCK.replace(
|
||||
'name = "security-check"\nversion = "0.1.0"\ndependencies',
|
||||
'name = "security-check"\nversion = "0.4.0"\ndependencies',
|
||||
),
|
||||
)
|
||||
|
||||
def test_without_cargo_toml_changes_nothing(self) -> None:
|
||||
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [])
|
||||
self.assertEqual(os.listdir(self.root), [])
|
||||
|
||||
def test_workspace_without_package_is_untouched(self) -> None:
|
||||
workspace = '[workspace]\nmembers = ["a"]\n'
|
||||
self._write("Cargo.toml", workspace)
|
||||
|
||||
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [])
|
||||
self.assertEqual(self._read("Cargo.toml"), workspace)
|
||||
|
||||
def test_without_lock_bumps_only_toml(self) -> None:
|
||||
toml = self._write("Cargo.toml", CARGO_TOML)
|
||||
|
||||
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [toml])
|
||||
|
||||
def test_preview_writes_nothing(self) -> None:
|
||||
self._write("Cargo.toml", CARGO_TOML)
|
||||
self._write("Cargo.lock", CARGO_LOCK)
|
||||
|
||||
update_cargo_version(self.root, "0.4.0", preview=True)
|
||||
|
||||
self.assertEqual(self._read("Cargo.toml"), CARGO_TOML)
|
||||
self.assertEqual(self._read("Cargo.lock"), CARGO_LOCK)
|
||||
|
||||
def test_lock_without_local_entry_fails_before_writing(self) -> None:
|
||||
self._write("Cargo.toml", CARGO_TOML)
|
||||
self._write("Cargo.lock", 'version = 4\n\n[[package]]\nname = "serde"\n')
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
update_cargo_version(self.root, "0.4.0")
|
||||
self.assertEqual(self._read("Cargo.toml"), CARGO_TOML)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -88,12 +88,15 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
|
||||
|
||||
self.assertIn("Missing version key", str(cm.exception))
|
||||
|
||||
def test_update_pyproject_version_raises_when_project_section_missing(self) -> None:
|
||||
def test_update_pyproject_version_skips_tool_only_pyproject(self) -> None:
|
||||
original = (
|
||||
textwrap.dedent(
|
||||
"""
|
||||
name = "example"
|
||||
version = "0.1.0"
|
||||
[dependency-groups]
|
||||
lint = ["ruff==0.17.3"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
@@ -104,10 +107,38 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(original)
|
||||
|
||||
with self.assertRaises(RuntimeError) as cm:
|
||||
update_pyproject_version(path, "1.2.3", preview=False)
|
||||
update_pyproject_version(path, "1.2.3", preview=False)
|
||||
|
||||
self.assertIn("Missing [project] section", str(cm.exception))
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
self.assertEqual(content, original)
|
||||
|
||||
def test_update_pyproject_version_skips_dynamic_version(self) -> None:
|
||||
original = (
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "example"
|
||||
dynamic = [
|
||||
"version",
|
||||
]
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "pyproject.toml")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(original)
|
||||
|
||||
update_pyproject_version(path, "1.2.3", preview=False)
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
self.assertEqual(content, original)
|
||||
|
||||
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestReleaseRetry(unittest.TestCase):
|
||||
class TestRetryRelease(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.push_branch")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
@@ -75,7 +75,7 @@ class TestRetryRelease(unittest.TestCase):
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
mock_push,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
@@ -83,14 +83,14 @@ class TestRetryRelease(unittest.TestCase):
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "release-fix", "v1.13.4"], preview=False
|
||||
mock_push.assert_called_once_with(
|
||||
".", [], sign=True, refs=("v1.13.4",), preview=False
|
||||
)
|
||||
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.push_branch")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
@@ -100,7 +100,7 @@ class TestRetryRelease(unittest.TestCase):
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
_mock_run,
|
||||
_mock_push,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
@@ -127,7 +127,7 @@ class TestRetryRelease(unittest.TestCase):
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.push_branch")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
@@ -137,7 +137,7 @@ class TestRetryRelease(unittest.TestCase):
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
mock_push,
|
||||
_mock_highest,
|
||||
_mock_update_latest,
|
||||
) -> None:
|
||||
@@ -145,8 +145,8 @@ class TestRetryRelease(unittest.TestCase):
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "main", "v2.0.0"], preview=False
|
||||
mock_push.assert_called_once_with(
|
||||
".", [], sign=True, refs=("v2.0.0",), preview=False
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -11,12 +13,23 @@ from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
|
||||
class TestDetermineCurrentVersion(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.repo = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _write(self, name: str, content: str) -> None:
|
||||
with open(os.path.join(self.repo, name), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
@patch("pkgmgr.actions.release.versioning.get_tags", return_value=[])
|
||||
def test_determine_current_version_no_tags_returns_zero(
|
||||
self,
|
||||
mock_get_tags,
|
||||
) -> None:
|
||||
ver = determine_current_version()
|
||||
ver = determine_current_version(self.repo)
|
||||
self.assertIsInstance(ver, SemVer)
|
||||
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
|
||||
mock_get_tags.assert_called_once()
|
||||
@@ -31,12 +44,29 @@ class TestDetermineCurrentVersion(unittest.TestCase):
|
||||
mock_get_tags.return_value = ["v0.1.0", "v1.2.3"]
|
||||
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3))
|
||||
|
||||
ver = determine_current_version()
|
||||
ver = determine_current_version(self.repo)
|
||||
|
||||
self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
|
||||
mock_get_tags.assert_called_once()
|
||||
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"])
|
||||
|
||||
@patch("pkgmgr.actions.release.versioning.get_tags", return_value=["v0.3.0"])
|
||||
def test_lagging_cargo_version_does_not_lower_the_base(self, _tags) -> None:
|
||||
self._write("Cargo.toml", '[package]\nname = "x"\nversion = "0.1.0"\n')
|
||||
self._write("CHANGELOG.md", "# Changelog\n\n## [0.3.0] - 2026-10-30\n")
|
||||
|
||||
self.assertEqual(determine_current_version(self.repo), SemVer(0, 3, 0))
|
||||
|
||||
@patch("pkgmgr.actions.release.versioning.get_tags", return_value=["v1.0.0"])
|
||||
def test_file_ahead_of_the_tag_raises_the_base(self, _tags) -> None:
|
||||
self._write("pyproject.toml", '[project]\nname = "x"\nversion = "1.4.0"\n')
|
||||
self._write(
|
||||
"CHANGELOG.md",
|
||||
"# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-10-01\n",
|
||||
)
|
||||
|
||||
self.assertEqual(determine_current_version(self.repo), SemVer(1, 4, 0))
|
||||
|
||||
|
||||
class TestBumpSemVer(unittest.TestCase):
|
||||
def test_bump_semver_major(self) -> None:
|
||||
|
||||
Reference in new issue
Block a user