Compare commits
No files matched your search
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
|
||||||
"Bash(*)"
|
|
||||||
],
|
|
||||||
"ask": [
|
"ask": [
|
||||||
"Skill(update-config)",
|
"Skill(update-config)",
|
||||||
"Skill(update-config:*)"
|
"Skill(update-config:*)",
|
||||||
|
"Bash(git commit)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git push)",
|
||||||
|
"Bash(git push *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "00:00"
|
||||||
|
rebase-strategy: "auto"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "python"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "00:00"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "ci"
|
||||||
@@ -15,12 +15,12 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Run hadolint (produce SARIF)
|
- name: Run hadolint (produce SARIF)
|
||||||
id: hadolint
|
id: hadolint
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
|
||||||
with:
|
with:
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
format: sarif
|
format: sarif
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
lint-shell:
|
lint-shell:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Install ShellCheck
|
- name: Install ShellCheck
|
||||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
- name: Run ShellCheck
|
- name: Run ShellCheck
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true # We need tags and main history for version comparison
|
fetch-tags: true # We need tags and main history for version comparison
|
||||||
|
|||||||
@@ -4,56 +4,118 @@ on:
|
|||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Mark stable commit"]
|
workflows: ["Mark stable commit"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
prepare:
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
releases: ${{ steps.info.outputs.releases }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository (with tags)
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve the releases this run publishes
|
||||||
|
id: info
|
||||||
|
env:
|
||||||
|
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.releases != '[]' }}
|
||||||
|
name: Build ${{ matrix.distro }} ${{ matrix.release.version }} for ${{ matrix.target.platform }}
|
||||||
|
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
|
||||||
|
release: ${{ fromJSON(needs.prepare.outputs.releases) }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout ${{ matrix.release.version }}
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ matrix.release.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: Build and push ${{ matrix.distro }} ${{ matrix.release.version }} for ${{ matrix.target.platform }}
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
VERSION: ${{ matrix.release.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.result == 'success' && needs.prepare.outputs.releases != '[]' }}
|
||||||
|
name: Merge ${{ matrix.distro }} ${{ matrix.release.version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
distro: *distros
|
||||||
|
release: ${{ fromJSON(needs.prepare.outputs.releases) }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository (with tags)
|
- name: Checkout ${{ matrix.release.version }}
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ matrix.release.sha }}
|
||||||
|
|
||||||
- name: Checkout workflow_run commit and refresh tags
|
|
||||||
env:
|
|
||||||
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: bash scripts/github/publish-containers/checkout-workflow-run-commit.sh
|
|
||||||
|
|
||||||
- name: Check whether tagged commit is on main
|
|
||||||
id: branch-check
|
|
||||||
env:
|
|
||||||
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: bash scripts/github/common/check-tagged-commit-on-main.sh
|
|
||||||
|
|
||||||
- name: Compute version and stable flag
|
|
||||||
id: info
|
|
||||||
if: steps.branch-check.outputs.is_on_main == 'true'
|
|
||||||
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
|
||||||
with:
|
with:
|
||||||
use: true
|
use: true
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Publish all images
|
- name: Join both platforms of ${{ matrix.distro }} ${{ matrix.release.version }} under its tags
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
|
||||||
env:
|
env:
|
||||||
OWNER: ${{ github.repository_owner }}
|
OWNER: ${{ github.repository_owner }}
|
||||||
VERSION: ${{ steps.info.outputs.version }}
|
VERSION: ${{ matrix.release.version }}
|
||||||
IS_STABLE: ${{ steps.info.outputs.is_stable }}
|
ALIASES: ${{ matrix.release.aliases }}
|
||||||
run: bash scripts/github/publish-containers/publish-container-images.sh
|
PKGMGR_DISTRO: ${{ matrix.distro }}
|
||||||
|
run: bash scripts/github/publish-containers/merge-container-images.sh
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v4
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show commit SHA
|
- name: Show commit SHA
|
||||||
run: git rev-parse HEAD
|
run: git rev-parse HEAD
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Pull base image (${{ matrix.distro }})
|
||||||
run: |
|
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
|
||||||
set -euo pipefail
|
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
|
||||||
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
||||||
run: |
|
run: |
|
||||||
@@ -39,7 +37,7 @@ jobs:
|
|||||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||||
-e NIX_CONFIG="${NIX_CONFIG}" \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
-w /opt/src/pkgmgr \
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Pull base image (${{ matrix.distro }})
|
||||||
run: |
|
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
|
||||||
set -euo pipefail
|
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
|
||||||
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
||||||
run: |
|
run: |
|
||||||
@@ -37,7 +35,7 @@ jobs:
|
|||||||
-v "$PWD":/opt/src/pkgmgr \
|
-v "$PWD":/opt/src/pkgmgr \
|
||||||
-e NIX_CONFIG="${NIX_CONFIG}" \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
-w /opt/src/pkgmgr \
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Update flake.lock
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock:
|
||||||
|
name: Update flake.lock
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v23
|
||||||
|
|
||||||
|
- name: Update flake.lock and open a pull request
|
||||||
|
uses: DeterminateSystems/update-flake-lock@v29
|
||||||
|
with:
|
||||||
|
pr-title: "chore(deps): update flake.lock"
|
||||||
|
pr-labels: |
|
||||||
|
dependencies
|
||||||
|
nix
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Scope against the skills repository
|
||||||
|
|
||||||
|
package-manager holds deterministic code that an operator can run on its own,
|
||||||
|
without an agent: repository, branch, worktree, release, packaging and
|
||||||
|
development tooling. Agent coordination (claims, messages, queues, scores,
|
||||||
|
prompts, session data) belongs to the
|
||||||
|
[skills repository](https://github.com/kevinveenbirkenbach/skills), whose skills
|
||||||
|
stay thin wrappers that call `pkgmgr`.
|
||||||
|
|
||||||
|
- A new helper that a human could sensibly run alone goes here as a `pkgmgr`
|
||||||
|
command, with its tests, never as a script inside a skill.
|
||||||
|
- Output that an agent filters further (claims, busy agents, scores) is
|
||||||
|
emitted as `--json`; the filtering stays in the skill.
|
||||||
|
- When the split of a helper is unclear, the operator decides.
|
||||||
|
|
||||||
|
## Working copy
|
||||||
|
|
||||||
|
The installed `pkgmgr` may run straight from the main checkout, so a
|
||||||
|
half-edited tree breaks it for everyone. Edit only in a worktree
|
||||||
|
(`pkgmgr worktree up <branch>`), never in the main checkout.
|
||||||
+205
@@ -1,5 +1,210 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-11-07
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Worktree: *worktree up* and *down* give each branch its own slot
|
||||||
|
- Worktree: *worktree flow* keeps pull sources and push targets per worktree
|
||||||
|
- Worktree: *up --pull* and *--push* record the flow; default is the base
|
||||||
|
- Branch: *branch tree* writes a hardened meta-tree of all repositories
|
||||||
|
- Branch: the tree carries each worktree's merge flow
|
||||||
|
- GC: *gc --report* prints a read-only disk inventory
|
||||||
|
- Review: a branch review renders as a local HTML page
|
||||||
|
- Publish: releases rebuild nightly; stable follows the tested build
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Push: signs only an unsigned HEAD instead of re-signing the whole range
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Push, release, gc: refuse a repository config that would run a program
|
||||||
|
- Git: hooks named in the config no longer run in hardened git calls
|
||||||
|
- Tests: the trusted PATH fixture comes from os.defpath, not the host
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Push: move repo-local sshCommand, credential helpers, hooks to global config
|
||||||
|
|
||||||
|
## [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:
|
||||||
|
|
||||||
|
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
|
||||||
|
- Config: *pkgmgr config edit* exits with the editor's code when it fails
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-10-19
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||||
|
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||||
|
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-10-18
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||||
|
- Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||||
|
- Release: findings the file already has are not blamed on the entry
|
||||||
|
- Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||||
|
- Release: without markdownlint-cli2, list items must use dash bullets
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-10-01
|
||||||
|
|
||||||
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
back as a heading and refuses with MD036
|
||||||
|
- Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- Headings now move below the release heading they are filed under
|
||||||
|
- The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
same breath they were printed
|
||||||
|
- They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
always ran plain make install
|
||||||
|
- A repository with install-python and no install died on "No rule to make
|
||||||
|
target" instead of being skipped
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- transform and lint were covered separately, so a transform producing lint
|
||||||
|
errors went unseen; one test now runs the first through the second
|
||||||
|
- Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- actions/setup-python 5 to 7
|
||||||
|
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
- Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-09-18
|
||||||
|
|
||||||
|
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
* The entry's separator only belongs there when another entry follows it.
|
||||||
|
* Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
* Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-09-18
|
||||||
|
|
||||||
|
Breaking
|
||||||
|
|
||||||
|
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
* Building an image now requires the base images to exist in the registry.
|
||||||
|
* *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
* *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
* A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
* A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
* The virgin workflows pull the base image instead of building it.
|
||||||
|
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
* The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
* It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
* Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
## [1.16.1] - 2026-07-29
|
||||||
|
|
||||||
|
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
## [1.16.0] - 2026-06-28
|
## [1.16.0] - 2026-06-28
|
||||||
|
|
||||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
|||||||
+5
-47
@@ -1,67 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# hadolint ignore=DL3006,InvalidDefaultArgInFrom
|
||||||
# Base image selector — overridden by build args / Makefile
|
# BASE_IMAGE carries no default on purpose: a default would build one
|
||||||
# ------------------------------------------------------------
|
# distribution's image under another distribution's tag whenever the build arg
|
||||||
|
# is forgotten. scripts/build/base.sh is the only place that resolves it.
|
||||||
ARG BASE_IMAGE
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: virgin
|
|
||||||
# - installs distro deps (incl. make)
|
|
||||||
# - no pkgmgr build
|
|
||||||
# - no entrypoint
|
|
||||||
# ============================================================
|
|
||||||
FROM ${BASE_IMAGE} AS virgin
|
|
||||||
SHELL ["/bin/bash", "-lc"]
|
SHELL ["/bin/bash", "-lc"]
|
||||||
|
|
||||||
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Copy scripts first so dependency installation can be cached
|
|
||||||
COPY scripts/installation/ scripts/installation/
|
|
||||||
|
|
||||||
# Install distro-specific build dependencies (including make)
|
|
||||||
RUN bash scripts/installation/dependencies.sh
|
|
||||||
|
|
||||||
# Virgin default
|
|
||||||
CMD ["bash"]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: full
|
|
||||||
# - inherits from virgin
|
|
||||||
# - builds + installs pkgmgr
|
|
||||||
# - sets entrypoint + default cmd
|
|
||||||
# - NOTE: does NOT run slim.sh (that is done in slim stage)
|
|
||||||
# ============================================================
|
|
||||||
FROM virgin AS full
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Copy full repository for build
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build and install distro-native package-manager package
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
echo "Building and installing package-manager via make install..."; \
|
echo "Building and installing package-manager via make install..."; \
|
||||||
make install; \
|
make install; \
|
||||||
rm -rf /build
|
rm -rf /build
|
||||||
|
|
||||||
# Entry point
|
|
||||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||||
|
|
||||||
WORKDIR /opt/src/pkgmgr
|
WORKDIR /opt/src/pkgmgr
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||||
CMD ["pkgmgr", "--help"]
|
CMD ["pkgmgr", "--help"]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: slim
|
|
||||||
# - based on full
|
|
||||||
# - runs slim.sh
|
|
||||||
# ============================================================
|
|
||||||
FROM full AS slim
|
|
||||||
|
|
||||||
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
|
||||||
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
build build-no-cache build-no-cache-all build-missing \
|
build build-no-cache build-no-cache-all build-missing \
|
||||||
delete-volumes purge \
|
delete-volumes purge \
|
||||||
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
||||||
|
lint \
|
||||||
setup setup-venv setup-nix
|
setup setup-venv setup-nix
|
||||||
|
|
||||||
# Distro
|
# Distro
|
||||||
@@ -16,20 +17,9 @@ export NIX_CONFIG
|
|||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Base images
|
# Base images
|
||||||
# (kept for documentation/reference; actual build logic is in scripts/build)
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
BASE_IMAGE_ARCH := archlinux:latest
|
BASE_IMAGES_TAG ?= latest
|
||||||
BASE_IMAGE_DEBIAN := debian:stable-slim
|
export BASE_IMAGES_TAG
|
||||||
BASE_IMAGE_UBUNTU := ubuntu:latest
|
|
||||||
BASE_IMAGE_FEDORA := fedora:latest
|
|
||||||
BASE_IMAGE_CENTOS := quay.io/centos/centos:stream9
|
|
||||||
|
|
||||||
# Make them available in scripts
|
|
||||||
export BASE_IMAGE_ARCH
|
|
||||||
export BASE_IMAGE_DEBIAN
|
|
||||||
export BASE_IMAGE_UBUNTU
|
|
||||||
export BASE_IMAGE_FEDORA
|
|
||||||
export BASE_IMAGE_CENTOS
|
|
||||||
|
|
||||||
# PYthon Unittest Pattern
|
# PYthon Unittest Pattern
|
||||||
TEST_PATTERN := test_*.py
|
TEST_PATTERN := test_*.py
|
||||||
@@ -62,17 +52,12 @@ setup-nix:
|
|||||||
# Docker build targets (delegated to scripts/build)
|
# Docker build targets (delegated to scripts/build)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
build:
|
build:
|
||||||
@bash scripts/build/image.sh --target virgin
|
|
||||||
@bash scripts/build/image.sh
|
@bash scripts/build/image.sh
|
||||||
|
|
||||||
build-missing-virgin:
|
build-missing:
|
||||||
@bash scripts/build/image.sh --target virgin --missing
|
|
||||||
|
|
||||||
build-missing: build-missing-virgin
|
|
||||||
@bash scripts/build/image.sh --missing
|
@bash scripts/build/image.sh --missing
|
||||||
|
|
||||||
build-no-cache:
|
build-no-cache:
|
||||||
@bash scripts/build/image.sh --target virgin --no-cache
|
|
||||||
@bash scripts/build/image.sh --no-cache
|
@bash scripts/build/image.sh --no-cache
|
||||||
|
|
||||||
build-no-cache-all:
|
build-no-cache-all:
|
||||||
@@ -82,6 +67,13 @@ build-no-cache-all:
|
|||||||
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
|
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Lint targets (run on the host, not in a container)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@bash scripts/lint/python.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Test targets (delegated to scripts/test)
|
# Test targets (delegated to scripts/test)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@@ -101,8 +93,8 @@ test-env-virtual: build-missing
|
|||||||
test-env-nix: build-missing
|
test-env-nix: build-missing
|
||||||
@bash scripts/test/test-env-nix.sh
|
@bash scripts/test/test-env-nix.sh
|
||||||
|
|
||||||
# Combined test target for local + CI (unit + integration + e2e)
|
# Combined test target for local + CI (lint + unit + integration + e2e)
|
||||||
test: test-env-virtual test-unit test-integration test-e2e
|
test: lint test-env-virtual test-unit test-integration test-e2e
|
||||||
|
|
||||||
delete-volumes:
|
delete-volumes:
|
||||||
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
|
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
|
||||||
|
|||||||
@@ -70,6 +70,89 @@ versioning features it can drive **end-to-end workflows**:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Worktrees and garbage collection
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `pkgmgr worktree up <branch> [--base REF] [--path DIR] [--pull REF ...] [--push BRANCH ...]` | Checks the branch out under `<directories.worktrees>/<host>/<owner>/<repo>/<branch>` or at `--path`, assigns a free slot, records the merge flow and runs the pinned `worktree-init` hook |
|
||||||
|
| `pkgmgr worktree flow [PATH] [--json]` | Shows the merge flow of the worktree at `PATH` (default: `.`) |
|
||||||
|
| `pkgmgr worktree flow set [PATH] (--pull REF ... \| --no-pull) (--push BRANCH ... \| --no-push) [--json]` | Replaces both lists of the merge flow |
|
||||||
|
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
|
||||||
|
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
|
||||||
|
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
|
||||||
|
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
|
||||||
|
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
|
||||||
|
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
|
||||||
|
| `pkgmgr worktree orphans --stale-days N --top N` | Lists merged and abandoned branches and worktrees with a proposal each |
|
||||||
|
|
||||||
|
A hook runs only when the `worktree_hooks` config pins its command under the
|
||||||
|
`<provider>/<account>/<repository>` of a repository managed in the pkgmgr
|
||||||
|
config, and only for that repository's directory; origin's URL, which the
|
||||||
|
repository's own config sets, never selects a pin. `{path}` and `{slot}` are
|
||||||
|
replaced; a
|
||||||
|
`worktree-init` or `worktree-fini` target in the repository's Makefile is
|
||||||
|
reported, never run, because an agent may have written it. The pinned program
|
||||||
|
must be an absolute path or a root-owned command on a `PATH` entry that root
|
||||||
|
owns and no group or other user can write, up to `/`, so a binary planted in a
|
||||||
|
user-writable directory such as a virtualenv never runs. The Nix store never
|
||||||
|
counts: it is group-writable, and any user can have the daemon build
|
||||||
|
root-owned content there, so pin an absolute path on Nix. A bare name runs as
|
||||||
|
the resolved file that was checked. Inside a user namespace other than the
|
||||||
|
initial one, root means the overflow uid, under which the real root's files
|
||||||
|
show there, as do those of every other unmapped owner. Outside such a
|
||||||
|
namespace a directory on a nosuid mount, where udisks2 and FUSE put user
|
||||||
|
mounts, never counts as root's. The hook runs with
|
||||||
|
stdin closed, only those `PATH` entries and a 30 minute timeout. Removal walks to
|
||||||
|
the worktree without following a symbolic link and skips a worktree path that
|
||||||
|
is not its own realpath. A pinned command that reads repository files, such as `make` or a
|
||||||
|
script in the repository, trusts the worktree's content, tracked or ignored.
|
||||||
|
|
||||||
|
Every cleanup is a dry run until `--apply`, and `--json` prints the plan for
|
||||||
|
other tools. The [agent skills](https://github.com/kevinveenbirkenbach/skills)
|
||||||
|
wrap these commands; see [AGENTS.md](AGENTS.md) for the split.
|
||||||
|
|
||||||
|
#### Merge flow
|
||||||
|
|
||||||
|
Every worktree can carry a merge flow that scripts and skills read: `pull`
|
||||||
|
lists the refs merged into the branch, in order, before a gate, deploy or
|
||||||
|
merge; `push` lists the branches the branch is merged into, in order, when it
|
||||||
|
is finished, each then pushed by the operator. It lives in
|
||||||
|
`pkgmgr/flow.yml` inside the worktree's own git directory (`.git` of the main
|
||||||
|
worktree, `<common>/worktrees/<id>` of a linked one), so it is never committed
|
||||||
|
and goes with the worktree:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: 1
|
||||||
|
branch: feature/x
|
||||||
|
pull:
|
||||||
|
- origin/main
|
||||||
|
- store-i18n
|
||||||
|
push:
|
||||||
|
- feature/iam-profile-rbac
|
||||||
|
- production
|
||||||
|
```
|
||||||
|
|
||||||
|
`branch` is the branch the flow was set on; another current branch shows as a
|
||||||
|
warning. `worktree up` records `--pull` and `--push`; without them pull is
|
||||||
|
the base the worktree came from and push that base's local branch
|
||||||
|
(`origin/main` gives `main`). `flow set` needs each list or
|
||||||
|
its `--no-pull`/`--no-push`, and writes atomically into a `pkgmgr/` of mode
|
||||||
|
0700. Every entry must pass `git check-ref-format --branch` and must not start
|
||||||
|
with `-`; a remote-tracking name such as `origin/main` passes. An invalid name
|
||||||
|
exits 2 and writes nothing. A flow file that is a symbolic link, no regular
|
||||||
|
file, larger than 64 KiB or malformed reads as `invalid`, with empty lists and
|
||||||
|
a warning, and is never followed.
|
||||||
|
|
||||||
|
`flow --json` prints `worktree` and `git_dir` (absolute paths), `branch` (the
|
||||||
|
current branch, `null` when detached), `flow_branch` (the file's `branch`),
|
||||||
|
`pull`, `push`, `source` (`file`, `none` or `invalid`) and `warnings`; it
|
||||||
|
exits 0 also without a flow, 2 when `PATH` is not a git worktree. In
|
||||||
|
`pkgmgr branch tree` every item carries `flow`: `{"pull", "push", "source"}`,
|
||||||
|
or `null` without a worktree or a flow; flow warnings join the repository's
|
||||||
|
`warnings`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture & Setup Map 🗺️
|
## Architecture & Setup Map 🗺️
|
||||||
|
|
||||||
The following diagram gives a full overview of:
|
The following diagram gives a full overview of:
|
||||||
@@ -169,26 +252,65 @@ nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- --help
|
|||||||
|
|
||||||
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
|
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
|
||||||
workflows.
|
workflows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Container types
|
#### Container images
|
||||||
|
|
||||||
Two container types are available.
|
One image per distribution, each installing PKGMGR as a distro-native package
|
||||||
|
on top of the matching base image from
|
||||||
|
[base-images](https://github.com/kevinveenbirkenbach/base-images). Every image
|
||||||
|
is a manifest list covering `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
All images live under `ghcr.io/kevinveenbirkenbach/`:
|
||||||
|
|
||||||
| Image type | Contains | Typical use |
|
| Tag | Contains |
|
||||||
| ---------- | ----------------------------- | ----------------------- |
|
| ------------------------------------- | --------------------------------------------------------- |
|
||||||
| **Virgin** | Base OS + system dependencies | Clean test environments |
|
| `pkgmgr-<distro>:latest` | newest build of the newest release on `main` |
|
||||||
| **Stable** | PKGMGR + Nix (flakes enabled) | Ready-to-use workflows |
|
| `pkgmgr-<distro>:stable` | newest build of the release the git tag `stable` marks |
|
||||||
|
| `pkgmgr-<distro>:<major>` | newest build of the newest release of that major |
|
||||||
|
| `pkgmgr-<distro>:<major>.<minor>` | newest build of the newest release of that minor |
|
||||||
|
| `pkgmgr-<distro>:<version>` | newest build of that release |
|
||||||
|
| `pkgmgr-<distro>:<version>.<unix-ts>` | one build, never moves |
|
||||||
|
|
||||||
Example images:
|
`<distro>` is one of `arch`, `debian`, `ubuntu`, `fedora`, `centos`. The Arch
|
||||||
|
image is published without the suffix as well, so `pkgmgr:stable` and
|
||||||
|
`pkgmgr-arch:stable` are the same image.
|
||||||
|
|
||||||
* Virgin: `pkgmgr-arch-virgin`
|
`<version>` is the PKGMGR release and `<unix-ts>` the build time, so
|
||||||
* Stable: `ghcr.io/kevinveenbirkenbach/pkgmgr:stable`
|
`2.3.0.1767225600` is a full version an updater can order and pin. Every tag of
|
||||||
|
one build points at the same image.
|
||||||
|
|
||||||
|
`stable` moves only once "Mark stable commit" has moved the git tag `stable`,
|
||||||
|
which it does after every test of that release passed. Every day at 03:00 UTC,
|
||||||
|
after the daily 00:00 UTC build of base-images, and on demand through the
|
||||||
|
workflow's manual trigger, two releases are rebuilt on the newest base image:
|
||||||
|
|
||||||
Use **virgin images** for isolated test runs,
|
- the newest `vX.Y.Z` release on `main`, moving `latest`, its prefixes and
|
||||||
use the **stable image** for fast, reproducible execution.
|
`<version>`, and adding `<version>.<unix-ts>`;
|
||||||
|
- the release the git tag `stable` marks, when that is an older one, moving
|
||||||
|
`stable` and `<version>`, adding its own `<version>.<unix-ts>`, and moving a
|
||||||
|
prefix only while it is still the newest release of that prefix.
|
||||||
|
|
||||||
|
With newest `2.4.0` and stable `2.3.0`, `2.4.0` takes `latest`, `2` and `2.4`,
|
||||||
|
and `2.3.0` takes `stable` and `2.3`. A security update in base-images thereby
|
||||||
|
reaches whoever pinned a major, a minor, a release or follows `stable`.
|
||||||
|
|
||||||
|
Each release is compared with its own published `<version>`. A rebuild whose
|
||||||
|
installed packages equal it pushes nothing, since a new timestamp tag would make
|
||||||
|
every pin bump for no change; a tag of that release that does not show the
|
||||||
|
published build yet, such as `stable` right after "Mark stable commit", is moved
|
||||||
|
without a new timestamp.
|
||||||
|
|
||||||
|
A rebuild runs the build scripts of the release it rebuilds, not those of
|
||||||
|
`main`, so the skip and the tag rules above only hold for releases tagged after
|
||||||
|
this scheme landed. The scripts of an earlier release know no aliases: its merge
|
||||||
|
step fails and leaves its tags where they are, until a newer release replaces
|
||||||
|
it as the newest and the stable one.
|
||||||
|
|
||||||
|
For a clean environment that carries the system dependencies but no PKGMGR,
|
||||||
|
pull `base-<distro>` from
|
||||||
|
[base-images](https://github.com/kevinveenbirkenbach/base-images) directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python311;
|
python = pkgs.python312;
|
||||||
pyPkgs = pkgs.python311Packages;
|
pyPkgs = pkgs.python312Packages;
|
||||||
in
|
in
|
||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.16.0";
|
version = "2.4.0";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
if pkgs ? ansible-core then pkgs.ansible-core
|
if pkgs ? ansible-core then pkgs.ansible-core
|
||||||
else pkgs.ansible;
|
else pkgs.ansible;
|
||||||
|
|
||||||
# Use the same Python version as the package (3.11)
|
# Use the same Python version as the package
|
||||||
python = pkgs.python311;
|
python = pkgs.python312;
|
||||||
|
|
||||||
pythonWithDeps = python.withPackages (ps: [
|
pythonWithDeps = python.withPackages (ps: [
|
||||||
ps.pip
|
ps.pip
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.gnupg
|
pkgs.gnupg
|
||||||
ansiblePkg
|
ansiblePkg
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.openldap
|
||||||
|
pkgs.cyrus_sasl
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=1.16.0
|
pkgver=2.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
@@ -1,3 +1,232 @@
|
|||||||
|
package-manager (2.4.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Worktree: *worktree up* and *down* give each branch its own slot
|
||||||
|
- Worktree: *worktree flow* keeps pull sources and push targets per worktree
|
||||||
|
- Worktree: *up --pull* and *--push* record the flow; default is the base
|
||||||
|
- Branch: *branch tree* writes a hardened meta-tree of all repositories
|
||||||
|
- Branch: the tree carries each worktree's merge flow
|
||||||
|
- GC: *gc --report* prints a read-only disk inventory
|
||||||
|
- Review: a branch review renders as a local HTML page
|
||||||
|
- Publish: releases rebuild nightly; stable follows the tested build
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Push: signs only an unsigned HEAD instead of re-signing the whole range
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Push, release, gc: refuse a repository config that would run a program
|
||||||
|
- Git: hooks named in the config no longer run in hardened git calls
|
||||||
|
- Tests: the trusted PATH fixture comes from os.defpath, not the host
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Push: move repo-local sshCommand, credential helpers, hooks to global config
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 07 Nov 2026 15:45:28 +0000
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
|
||||||
|
- Config: *pkgmgr config edit* exits with the editor's code when it fails
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 29 Oct 2026 13:57:31 +0100
|
||||||
|
|
||||||
|
package-manager (2.0.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||||
|
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||||
|
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 19 Oct 2026 10:06:02 +0200
|
||||||
|
|
||||||
|
package-manager (2.0.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||||
|
- Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||||
|
- Release: findings the file already has are not blamed on the entry
|
||||||
|
- Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||||
|
- Release: without markdownlint-cli2, list items must use dash bullets
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 18 Oct 2026 23:33:52 +0200
|
||||||
|
|
||||||
|
package-manager (2.0.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
back as a heading and refuses with MD036
|
||||||
|
- Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- Headings now move below the release heading they are filed under
|
||||||
|
- The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
same breath they were printed
|
||||||
|
- They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
always ran plain make install
|
||||||
|
- A repository with install-python and no install died on "No rule to make
|
||||||
|
target" instead of being skipped
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- transform and lint were covered separately, so a transform producing lint
|
||||||
|
errors went unseen; one test now runs the first through the second
|
||||||
|
- Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- actions/setup-python 5 to 7
|
||||||
|
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
- Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 01 Oct 2026 17:47:12 +0200
|
||||||
|
|
||||||
|
package-manager (2.0.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
* The entry's separator only belongs there when another entry follows it.
|
||||||
|
* Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
* Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 17:15:35 +0200
|
||||||
|
|
||||||
|
package-manager (2.0.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Breaking
|
||||||
|
|
||||||
|
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
* Building an image now requires the base images to exist in the registry.
|
||||||
|
* *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
* *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
* A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
* A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
* The virgin workflows pull the base image instead of building it.
|
||||||
|
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
* The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
* It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
* Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 16:29:32 +0200
|
||||||
|
|
||||||
|
package-manager (1.16.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 29 Jul 2026 16:41:21 +0200
|
||||||
|
|
||||||
package-manager (1.16.0-1) unstable; urgency=medium
|
package-manager (1.16.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 1.16.0
|
Version: 2.4.0
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||||
|
|
||||||
@@ -74,6 +74,199 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
|||||||
/usr/lib/package-manager/
|
/usr/lib/package-manager/
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Sat Nov 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.4.0-1
|
||||||
|
- Added:
|
||||||
|
|
||||||
|
- - Worktree: *worktree up* and *down* give each branch its own slot
|
||||||
|
- - Worktree: *worktree flow* keeps pull sources and push targets per worktree
|
||||||
|
- - Worktree: *up --pull* and *--push* record the flow; default is the base
|
||||||
|
- - Branch: *branch tree* writes a hardened meta-tree of all repositories
|
||||||
|
- - Branch: the tree carries each worktree's merge flow
|
||||||
|
- - GC: *gc --report* prints a read-only disk inventory
|
||||||
|
- - Review: a branch review renders as a local HTML page
|
||||||
|
- - Publish: releases rebuild nightly; stable follows the tested build
|
||||||
|
|
||||||
|
- Changed:
|
||||||
|
|
||||||
|
- - Push: signs only an unsigned HEAD instead of re-signing the whole range
|
||||||
|
|
||||||
|
- Fixed:
|
||||||
|
|
||||||
|
- - Push, release, gc: refuse a repository config that would run a program
|
||||||
|
- - Git: hooks named in the config no longer run in hardened git calls
|
||||||
|
- - Tests: the trusted PATH fixture comes from os.defpath, not the host
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - Push: move repo-local sshCommand, credential helpers, hooks to global config
|
||||||
|
|
||||||
|
* 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:
|
||||||
|
|
||||||
|
- - Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
|
||||||
|
- - Config: *pkgmgr config edit* exits with the editor's code when it fails
|
||||||
|
|
||||||
|
* Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
|
||||||
|
- Fixed:
|
||||||
|
|
||||||
|
- - CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||||
|
- - CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||||
|
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
* Sun Oct 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.3-1
|
||||||
|
- Fixed:
|
||||||
|
|
||||||
|
- - Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||||
|
- - Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||||
|
- - Release: findings the file already has are not blamed on the entry
|
||||||
|
- - Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||||
|
- - Release: without markdownlint-cli2, list items must use dash bullets
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||||
|
|
||||||
|
* Thu Oct 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.2-1
|
||||||
|
- Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
- ### Fixed
|
||||||
|
|
||||||
|
- - A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
- back as a heading and refuses with MD036
|
||||||
|
- - Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- - Headings now move below the release heading they are filed under
|
||||||
|
- - The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
- same breath they were printed
|
||||||
|
- - They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- - The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
- always ran plain make install
|
||||||
|
- - A repository with install-python and no install died on "No rule to make
|
||||||
|
- target" instead of being skipped
|
||||||
|
|
||||||
|
- ### Tests
|
||||||
|
|
||||||
|
- - transform and lint were covered separately, so a transform producing lint
|
||||||
|
- errors went unseen; one test now runs the first through the second
|
||||||
|
- - Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
- ### Dependencies
|
||||||
|
|
||||||
|
- - docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- - actions/setup-python 5 to 7
|
||||||
|
- - DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
- ### Upgrading
|
||||||
|
|
||||||
|
- - Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1
|
||||||
|
- * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
- * The entry's separator only belongs there when another entry follows it.
|
||||||
|
- * Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
- * Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
|
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.0-1
|
||||||
|
- Breaking
|
||||||
|
|
||||||
|
- * The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
- * Building an image now requires the base images to exist in the registry.
|
||||||
|
- * *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
- * The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
- * *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
- * A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
- Changed
|
||||||
|
|
||||||
|
- * *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
- * A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
- * The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
- * Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
- * *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
- * The virgin workflows pull the base image instead of building it.
|
||||||
|
- * The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
- * It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
- * *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
- * The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
- * The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
- * It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
- * Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
- * A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
- * *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
- * *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
* Wed Jul 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.1-1
|
||||||
|
- * *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
- * Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
- * Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
- * The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
- * New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
- * New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
||||||
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||||
|
|||||||
+3
-2
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.16.0"
|
version = "2.4.0"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -21,7 +21,7 @@ authors = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"tomli; python_version < \"3.11\"",
|
"tomli; python_version < \"3.11\"",
|
||||||
"jinja2>=3.1"
|
"jinja2>=3.1.6"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -52,3 +52,4 @@ include = ["pkgmgr*"]
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||||
|
"pkgmgr.actions.review" = ["*.j2"]
|
||||||
+43
-10
@@ -1,20 +1,53 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${BASE_IMAGE_ARCH:=archlinux:latest}"
|
# The distribution bases this repository builds on. They are published by
|
||||||
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
# https://github.com/kevinveenbirkenbach/base-images, one multi-platform image
|
||||||
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
# per distribution, and carry the build dependencies the package build needs.
|
||||||
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
#
|
||||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
|
# Env overrides exist for testing an unpublished base; the registry namespace
|
||||||
|
# follows the images repository, not this one.
|
||||||
|
|
||||||
|
: "${BASE_IMAGES_REGISTRY:=ghcr.io}"
|
||||||
|
: "${BASE_IMAGES_OWNER:=kevinveenbirkenbach}"
|
||||||
|
: "${BASE_IMAGES_TAG:=latest}"
|
||||||
|
|
||||||
resolve_base_image() {
|
resolve_base_image() {
|
||||||
local PKGMGR_DISTRO="$1"
|
local PKGMGR_DISTRO="$1"
|
||||||
case "$PKGMGR_DISTRO" in
|
case "$PKGMGR_DISTRO" in
|
||||||
arch) echo "$BASE_IMAGE_ARCH" ;;
|
arch|manjaro|debian|ubuntu|fedora|centos)
|
||||||
debian) echo "$BASE_IMAGE_DEBIAN" ;;
|
echo "${BASE_IMAGES_REGISTRY}/${BASE_IMAGES_OWNER}/base-${PKGMGR_DISTRO}:${BASE_IMAGES_TAG}"
|
||||||
ubuntu) echo "$BASE_IMAGE_UBUNTU" ;;
|
;;
|
||||||
fedora) echo "$BASE_IMAGE_FEDORA" ;;
|
|
||||||
centos) echo "$BASE_IMAGE_CENTOS" ;;
|
|
||||||
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Platforms each distribution's base offers; see base-images/scripts/build/distros.sh.
|
||||||
|
resolve_platforms() {
|
||||||
|
local PKGMGR_DISTRO="$1"
|
||||||
|
case "$PKGMGR_DISTRO" in
|
||||||
|
arch|manjaro|debian|ubuntu|fedora|centos) echo "linux/amd64,linux/arm64" ;;
|
||||||
|
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
package_set_cmd() {
|
||||||
|
local PKGMGR_DISTRO="$1"
|
||||||
|
case "$PKGMGR_DISTRO" in
|
||||||
|
arch|manjaro) echo 'pacman -Q' ;;
|
||||||
|
debian|ubuntu) echo 'dpkg-query -W' ;;
|
||||||
|
fedora|centos) echo 'rpm -qa | sort' ;;
|
||||||
|
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
version_prefixes() {
|
||||||
|
local -a parts
|
||||||
|
IFS=. read -ra parts <<< "$1"
|
||||||
|
local acc="${parts[0]}" i
|
||||||
|
echo "${acc}"
|
||||||
|
for ((i = 1; i < ${#parts[@]}; i++)); do
|
||||||
|
acc+=".${parts[i]}"
|
||||||
|
echo "${acc}"
|
||||||
|
done
|
||||||
|
}
|
||||||
+141
-60
@@ -10,22 +10,21 @@ source "${SCRIPT_DIR}/base.sh"
|
|||||||
|
|
||||||
NO_CACHE=0
|
NO_CACHE=0
|
||||||
MISSING_ONLY=0
|
MISSING_ONLY=0
|
||||||
TARGET=""
|
|
||||||
IMAGE_TAG="" # local image name or base tag (without registry)
|
IMAGE_TAG="" # local image name or base tag (without registry)
|
||||||
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
|
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
|
||||||
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
|
PUBLISH=0 # if 1 -> push <version>, <version>.<unix-ts> and the --aliases, plus arch aliases
|
||||||
REGISTRY="" # e.g. ghcr.io
|
REGISTRY="" # e.g. ghcr.io
|
||||||
OWNER="" # e.g. github org/user
|
OWNER="" # e.g. github org/user
|
||||||
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
|
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
|
||||||
VERSION="" # X.Y.Z (required for --publish)
|
VERSION="" # X.Y.Z (required for --publish)
|
||||||
IS_STABLE="false" # "true" -> publish stable tags
|
ALIASES="" # moving tags besides <version>, e.g. "latest 2 2.4 stable"
|
||||||
|
ALIASES_SET=0 # 1 once --aliases was given, also when empty
|
||||||
|
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"
|
DEFAULT_DISTRO="arch"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
local default_tag="pkgmgr-${PKGMGR_DISTRO}"
|
local default_tag="pkgmgr-${PKGMGR_DISTRO}"
|
||||||
if [[ -n "${TARGET:-}" ]]; then
|
|
||||||
default_tag="${default_tag}-${TARGET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
||||||
@@ -33,21 +32,29 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
|||||||
Build options:
|
Build options:
|
||||||
--missing Build only if the image does not already exist (local build only)
|
--missing Build only if the image does not already exist (local build only)
|
||||||
--no-cache Build with --no-cache
|
--no-cache Build with --no-cache
|
||||||
--target <name> Build a specific Dockerfile target (e.g. virgin, slim)
|
|
||||||
--tag <image> Override the output image tag (default: ${default_tag})
|
--tag <image> Override the output image tag (default: ${default_tag})
|
||||||
|
|
||||||
Publish options:
|
Publish options:
|
||||||
--push Push the built image (uses docker buildx build --push)
|
--push Push the built image (uses docker buildx build --push)
|
||||||
--publish Publish semantic tags (latest, <version>, optional stable) + arch aliases
|
--publish Publish <version>, <version>.<unix-ts> and every --aliases tag, plus arch
|
||||||
|
aliases
|
||||||
--registry <reg> Registry (e.g. ghcr.io)
|
--registry <reg> Registry (e.g. ghcr.io)
|
||||||
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
|
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
|
||||||
--repo-prefix <name> Image base name (default: pkgmgr)
|
--repo-prefix <name> Image base name (default: pkgmgr)
|
||||||
--version <X.Y.Z> Version for --publish
|
--version <X.Y.Z> Version for --publish
|
||||||
--stable <true|false> Whether to publish :stable tags (default: false)
|
--aliases "<tags>" Moving tags besides <version>, space separated, may be empty
|
||||||
|
(e.g. "latest 2 2.4 stable")
|
||||||
|
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>,
|
||||||
|
skipped when its package set equals the published <version>
|
||||||
|
--merge Join the <version>-<arch> tags of all platforms under the publish tags;
|
||||||
|
when <version> already joins exactly these images, only the aliases
|
||||||
|
that do not are moved and no <version>.<unix-ts> is added
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- --publish implies --push and requires --registry, --owner, and --version.
|
- --publish implies --push and requires --registry, --owner, and --version.
|
||||||
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
|
- --publish without --platform requires --aliases.
|
||||||
|
- --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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +62,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--no-cache) NO_CACHE=1; shift ;;
|
--no-cache) NO_CACHE=1; shift ;;
|
||||||
--missing) MISSING_ONLY=1; shift ;;
|
--missing) MISSING_ONLY=1; shift ;;
|
||||||
--target)
|
|
||||||
TARGET="${2:-}"
|
|
||||||
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--tag)
|
--tag)
|
||||||
IMAGE_TAG="${2:-}"
|
IMAGE_TAG="${2:-}"
|
||||||
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
|
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
|
||||||
@@ -87,11 +89,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
|
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--stable)
|
--aliases)
|
||||||
IS_STABLE="${2:-}"
|
[[ $# -ge 2 ]] || { echo "ERROR: --aliases requires a value, empty for none"; exit 2; }
|
||||||
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
|
ALIASES="$2"
|
||||||
|
ALIASES_SET=1
|
||||||
shift 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 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*)
|
*)
|
||||||
echo "ERROR: Unknown argument: $1" >&2
|
echo "ERROR: Unknown argument: $1" >&2
|
||||||
@@ -104,9 +113,6 @@ done
|
|||||||
# Derive default local tag if not provided
|
# Derive default local tag if not provided
|
||||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||||
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
|
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
|
||||||
@@ -128,8 +134,24 @@ if [[ "${PUBLISH}" == "1" ]]; then
|
|||||||
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
|
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
|
||||||
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; }
|
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; }
|
||||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
|
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
|
||||||
|
[[ -n "${PLATFORM}" || "${ALIASES_SET}" == "1" ]] \
|
||||||
|
|| { echo "ERROR: --publish without --platform requires --aliases"; exit 2; }
|
||||||
fi
|
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}"
|
||||||
|
STAMP="$(date -u +%s)"
|
||||||
|
|
||||||
# Guard: --push without --publish requires fully-qualified --tag
|
# Guard: --push without --publish requires fully-qualified --tag
|
||||||
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
|
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
|
||||||
if [[ "${IMAGE_TAG}" != */* ]]; then
|
if [[ "${IMAGE_TAG}" != */* ]]; then
|
||||||
@@ -143,7 +165,7 @@ echo "------------------------------------------------------------"
|
|||||||
echo "[build] Building image"
|
echo "[build] Building image"
|
||||||
echo "distro = ${PKGMGR_DISTRO}"
|
echo "distro = ${PKGMGR_DISTRO}"
|
||||||
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
||||||
if [[ -n "${TARGET}" ]]; then echo "target = ${TARGET}"; fi
|
echo "platforms = ${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}"
|
||||||
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
||||||
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
||||||
if [[ "${PUBLISH}" == "1" ]]; then
|
if [[ "${PUBLISH}" == "1" ]]; then
|
||||||
@@ -151,7 +173,8 @@ if [[ "${PUBLISH}" == "1" ]]; then
|
|||||||
echo "registry = ${REGISTRY}"
|
echo "registry = ${REGISTRY}"
|
||||||
echo "owner = ${OWNER}"
|
echo "owner = ${OWNER}"
|
||||||
echo "version = ${VERSION}"
|
echo "version = ${VERSION}"
|
||||||
echo "stable = ${IS_STABLE}"
|
echo "stamp = ${STAMP}"
|
||||||
|
echo "aliases = ${ALIASES}"
|
||||||
fi
|
fi
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
|
|
||||||
@@ -162,55 +185,113 @@ if [[ "${NO_CACHE}" == "1" ]]; then
|
|||||||
build_args+=(--no-cache)
|
build_args+=(--no-cache)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${TARGET}" ]]; then
|
publish_bases() {
|
||||||
build_args+=(--target "${TARGET}")
|
echo "${DISTRO_TAG_BASE}"
|
||||||
fi
|
|
||||||
|
|
||||||
compute_publish_tags() {
|
|
||||||
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
|
||||||
local alias_tag_base=""
|
|
||||||
|
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
distro_tag_base="${distro_tag_base}-${TARGET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
||||||
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
echo "${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
alias_tag_base="${alias_tag_base}-${TARGET}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local tags=()
|
|
||||||
tags+=("${distro_tag_base}:latest")
|
|
||||||
tags+=("${distro_tag_base}:${VERSION}")
|
|
||||||
|
|
||||||
if [[ "${IS_STABLE}" == "true" ]]; then
|
|
||||||
tags+=("${distro_tag_base}:stable")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${alias_tag_base}" ]]; then
|
|
||||||
tags+=("${alias_tag_base}:latest")
|
|
||||||
tags+=("${alias_tag_base}:${VERSION}")
|
|
||||||
if [[ "${IS_STABLE}" == "true" ]]; then
|
|
||||||
tags+=("${alias_tag_base}:stable")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "${tags[@]}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${PUSH}" == "1" ]]; then
|
moving_tags() {
|
||||||
|
local base alias
|
||||||
|
local -a aliases
|
||||||
|
read -ra aliases <<< "${ALIASES}"
|
||||||
|
while read -r base; do
|
||||||
|
echo "${base}:${VERSION}"
|
||||||
|
for alias in "${aliases[@]}"; do
|
||||||
|
echo "${base}:${alias}"
|
||||||
|
done
|
||||||
|
done < <(publish_bases)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp_tags() {
|
||||||
|
local base
|
||||||
|
while read -r base; do
|
||||||
|
echo "${base}:${VERSION}.${STAMP}"
|
||||||
|
done < <(publish_bases)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_index() {
|
||||||
|
docker buildx imagetools inspect --raw "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
digest_of() {
|
||||||
|
docker buildx imagetools inspect --format '{{json .Manifest.Digest}}' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${MERGE}" == "1" ]]; then
|
||||||
|
sources=()
|
||||||
|
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
|
||||||
|
for p in "${platforms[@]}"; do
|
||||||
|
sources+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
|
||||||
|
done
|
||||||
|
|
||||||
|
mapfile -t moving < <(moving_tags)
|
||||||
|
planned="$(docker buildx imagetools create --dry-run -t "${DISTRO_TAG_BASE}:${VERSION}" "${sources[@]}")"
|
||||||
|
tags=()
|
||||||
|
if [[ "$(raw_index "${DISTRO_TAG_BASE}:${VERSION}")" == "${planned}" ]]; then
|
||||||
|
for t in "${moving[@]}"; do
|
||||||
|
if [[ "$(raw_index "${t}")" != "${planned}" ]]; then
|
||||||
|
tags+=("${t}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
mapfile -t stamps < <(stamp_tags)
|
||||||
|
tags=("${moving[@]}" "${stamps[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((${#tags[@]} == 0)); then
|
||||||
|
echo "[build] ${PKGMGR_DISTRO} ${VERSION}: every tag already joins these platform images, nothing to push."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
create_args=()
|
||||||
|
for t in "${tags[@]}"; do
|
||||||
|
create_args+=(-t "$t")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[build] Running: docker buildx imagetools create ${create_args[*]} ${sources[*]}"
|
||||||
|
docker buildx imagetools create "${create_args[@]}" "${sources[@]}"
|
||||||
|
|
||||||
|
version_digest="$(digest_of "${DISTRO_TAG_BASE}:${VERSION}")"
|
||||||
|
for t in "${moving[@]}"; do
|
||||||
|
if [[ "$(digest_of "${t}")" != "${version_digest}" ]]; then
|
||||||
|
echo "[build] ❌ ${PKGMGR_DISTRO}: :${VERSION} and ${t} point at different images." >&2
|
||||||
|
echo "[build] Whoever pinned ${t##*:} stopped receiving what ${VERSION} gets." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "[build] ✅ ${PKGMGR_DISTRO}: every tag of ${VERSION} == ${version_digest}"
|
||||||
|
elif [[ "${PUSH}" == "1" ]]; then
|
||||||
|
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
|
||||||
|
probe="${REPO_PREFIX}-${PKGMGR_DISTRO}:probe"
|
||||||
|
docker buildx build --load --platform "${PLATFORM}" -t "${probe}" "${build_args[@]}" .
|
||||||
|
|
||||||
|
package_set="$(package_set_cmd "${PKGMGR_DISTRO}")"
|
||||||
|
built_set="$(docker run --rm --platform "${PLATFORM}" --entrypoint bash "${probe}" -lc "${package_set}" | sha256sum)"
|
||||||
|
published_set="$(docker run --rm --pull always --platform "${PLATFORM}" --entrypoint bash "${DISTRO_TAG_BASE}:${VERSION}" -lc "${package_set}" 2>/dev/null | sha256sum || true)"
|
||||||
|
if [[ "${built_set}" == "${published_set}" ]]; then
|
||||||
|
echo "[build] ${PKGMGR_DISTRO} ${PLATFORM} unchanged since the published ${VERSION}, nothing to push."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
bx_args=(docker buildx build --push)
|
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
|
while IFS= read -r t; do
|
||||||
bx_args+=(-t "$t")
|
bx_args+=(-t "$t")
|
||||||
done < <(compute_publish_tags)
|
done < <(moving_tags)
|
||||||
|
while IFS= read -r t; do
|
||||||
|
bx_args+=(-t "$t")
|
||||||
|
done < <(stamp_tags)
|
||||||
else
|
else
|
||||||
bx_args+=(-t "${IMAGE_TAG}")
|
bx_args+=(-t "${IMAGE_TAG}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
|
||||||
bx_args+=("${build_args[@]}")
|
bx_args+=("${build_args[@]}")
|
||||||
bx_args+=(.)
|
bx_args+=(.)
|
||||||
|
|
||||||
|
|||||||
+18
-36
@@ -1,33 +1,33 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
|
# Publish one image per distro, each covering every platform its base offers.
|
||||||
#
|
#
|
||||||
# Required env:
|
# Required env:
|
||||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||||
# VERSION (e.g. 1.2.3)
|
# VERSION (e.g. 1.2.3)
|
||||||
|
# ALIASES moving tags besides VERSION, space separated, may be empty (e.g. "latest 1 1.2 stable")
|
||||||
#
|
#
|
||||||
# Optional env:
|
# Optional env:
|
||||||
# REGISTRY (default: ghcr.io)
|
# REGISTRY (default: ghcr.io)
|
||||||
# IS_STABLE (default: false)
|
|
||||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# - This expects Dockerfile targets: virgin, full (default), slim
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# shellcheck source=./scripts/build/base.sh
|
||||||
|
source "${SCRIPT_DIR}/base.sh"
|
||||||
|
|
||||||
REGISTRY="${REGISTRY:-ghcr.io}"
|
REGISTRY="${REGISTRY:-ghcr.io}"
|
||||||
IS_STABLE="${IS_STABLE:-false}"
|
|
||||||
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
|
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
|
||||||
|
|
||||||
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
|
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
|
||||||
: "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}"
|
: "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}"
|
||||||
|
: "${ALIASES?Environment variable ALIASES must be set, empty for none (e.g. \"latest 1 1.2 stable\")}"
|
||||||
|
|
||||||
echo "[publish] REGISTRY=${REGISTRY}"
|
echo "[publish] REGISTRY=${REGISTRY}"
|
||||||
echo "[publish] OWNER=${OWNER}"
|
echo "[publish] OWNER=${OWNER}"
|
||||||
echo "[publish] VERSION=${VERSION}"
|
echo "[publish] VERSION=${VERSION}"
|
||||||
echo "[publish] IS_STABLE=${IS_STABLE}"
|
echo "[publish] ALIASES=${ALIASES}"
|
||||||
echo "[publish] DISTROS=${DISTROS}"
|
echo "[publish] DISTROS=${DISTROS}"
|
||||||
|
|
||||||
for d in ${DISTROS}; do
|
for d in ${DISTROS}; do
|
||||||
@@ -36,41 +36,23 @@ for d in ${DISTROS}; do
|
|||||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")"
|
||||||
# virgin
|
for p in "${platforms[@]}"; do
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
# ----------------------------------------------------------
|
--publish \
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
--registry "${REGISTRY}" \
|
||||||
--publish \
|
--owner "${OWNER}" \
|
||||||
--registry "${REGISTRY}" \
|
--version "${VERSION}" \
|
||||||
--owner "${OWNER}" \
|
--platform "${p}"
|
||||||
--version "${VERSION}" \
|
done
|
||||||
--stable "${IS_STABLE}" \
|
|
||||||
--target virgin
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# full (default target)
|
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
--owner "${OWNER}" \
|
--owner "${OWNER}" \
|
||||||
--version "${VERSION}" \
|
--version "${VERSION}" \
|
||||||
--stable "${IS_STABLE}"
|
--aliases "${ALIASES}" \
|
||||||
|
--merge
|
||||||
# ----------------------------------------------------------
|
|
||||||
# slim
|
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
|
|
||||||
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
|
||||||
--publish \
|
|
||||||
--registry "${REGISTRY}" \
|
|
||||||
--owner "${OWNER}" \
|
|
||||||
--version "${VERSION}" \
|
|
||||||
--stable "${IS_STABLE}" \
|
|
||||||
--target slim
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
log() { echo "[cleanup] $*"; }
|
|
||||||
warn() { echo "[cleanup][WARN] $*" >&2; }
|
|
||||||
|
|
||||||
MODE="${MODE:-safe}" # safe | aggressive
|
|
||||||
# safe: caches/logs/tmp only
|
|
||||||
# aggressive: safe + docs/man/info (optional)
|
|
||||||
|
|
||||||
ID="unknown"
|
|
||||||
if [ -f /etc/os-release ]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
. /etc/os-release
|
|
||||||
ID="${ID:-unknown}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Starting image cleanup"
|
|
||||||
log "Mode: ${MODE}"
|
|
||||||
log "Detected OS: ${ID}"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Package manager caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
case "${ID}" in
|
|
||||||
alpine)
|
|
||||||
log "Cleaning apk cache"
|
|
||||||
if [ -d /var/cache/apk ]; then
|
|
||||||
du -sh /var/cache/apk || true
|
|
||||||
rm -rvf /var/cache/apk/* || true
|
|
||||||
else
|
|
||||||
log "apk cache directory not present (already clean)"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
arch)
|
|
||||||
log "Cleaning pacman cache"
|
|
||||||
du -sh /var/cache/pacman/pkg 2>/dev/null || true
|
|
||||||
pacman -Scc --noconfirm || true
|
|
||||||
rm -rvf /var/cache/pacman/pkg/* || true
|
|
||||||
;;
|
|
||||||
debian|ubuntu)
|
|
||||||
log "Cleaning apt cache"
|
|
||||||
du -sh /var/lib/apt/lists 2>/dev/null || true
|
|
||||||
apt-get clean || true
|
|
||||||
rm -rvf /var/lib/apt/lists/* || true
|
|
||||||
;;
|
|
||||||
fedora)
|
|
||||||
log "Cleaning dnf cache"
|
|
||||||
du -sh /var/cache/dnf 2>/dev/null || true
|
|
||||||
dnf clean all || true
|
|
||||||
rm -rvf /var/cache/dnf/* || true
|
|
||||||
;;
|
|
||||||
centos|rhel)
|
|
||||||
log "Cleaning yum/dnf cache"
|
|
||||||
du -sh /var/cache/yum /var/cache/dnf 2>/dev/null || true
|
|
||||||
(command -v dnf >/dev/null 2>&1 && dnf clean all) || true
|
|
||||||
(command -v yum >/dev/null 2>&1 && yum clean all) || true
|
|
||||||
rm -rvf /var/cache/yum/* /var/cache/dnf/* || true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
warn "Unknown distro '${ID}' — skipping package manager cleanup"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Python caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning pip cache"
|
|
||||||
du -sh /root/.cache/pip 2>/dev/null || true
|
|
||||||
rm -rvf /root/.cache/pip 2>/dev/null || true
|
|
||||||
rm -rvf /home/*/.cache/pip 2>/dev/null || true
|
|
||||||
|
|
||||||
log "Cleaning __pycache__ directories"
|
|
||||||
find /opt /usr /root /home -type d -name "__pycache__" -print -prune 2>/dev/null || true
|
|
||||||
find /opt /usr /root /home -type d -name "__pycache__" -prune -exec rm -rvf {} + 2>/dev/null || true
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Logs (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Truncating log files (keeping paths intact)"
|
|
||||||
if [ -d /var/log ]; then
|
|
||||||
find /var/log -type f -name "*.log" -print 2>/dev/null || true
|
|
||||||
find /var/log -type f -name "*.log" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
|
||||||
|
|
||||||
find /var/log -type f -name "*.out" -print 2>/dev/null || true
|
|
||||||
find /var/log -type f -name "*.out" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v journalctl >/dev/null 2>&1; then
|
|
||||||
log "Vacuuming journald logs"
|
|
||||||
journalctl --disk-usage || true
|
|
||||||
journalctl --vacuum-size=10M || true
|
|
||||||
journalctl --vacuum-time=1s || true
|
|
||||||
journalctl --disk-usage || true
|
|
||||||
else
|
|
||||||
log "journald not present (skipping)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Temporary files (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning temporary directories"
|
|
||||||
if [ -d /tmp ]; then
|
|
||||||
du -sh /tmp 2>/dev/null || true
|
|
||||||
rm -rvf /tmp/* || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d /var/tmp ]; then
|
|
||||||
du -sh /var/tmp 2>/dev/null || true
|
|
||||||
rm -rvf /var/tmp/* || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Generic caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning generic caches"
|
|
||||||
du -sh /root/.cache 2>/dev/null || true
|
|
||||||
rm -rvf /root/.cache/* 2>/dev/null || true
|
|
||||||
rm -rvf /home/*/.cache/* 2>/dev/null || true
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Optional aggressive extras (still safe for runtime)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if [[ "${MODE}" == "aggressive" ]]; then
|
|
||||||
log "Aggressive mode enabled: removing docs/man/info"
|
|
||||||
du -sh /usr/share/doc /usr/share/man /usr/share/info 2>/dev/null || true
|
|
||||||
rm -rvf /usr/share/doc/* /usr/share/man/* /usr/share/info/* 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Cleanup finished successfully"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
WORKFLOW_RUN_SHA="${WORKFLOW_RUN_SHA:?WORKFLOW_RUN_SHA must be set}"
|
|
||||||
|
|
||||||
git checkout -f "${WORKFLOW_RUN_SHA}"
|
|
||||||
git fetch --tags --force
|
|
||||||
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
|
|
||||||
@@ -1,23 +1,91 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Param: WORKFLOW_RUN_SHA commit the "Mark stable commit" run tagged; empty on a scheduled or
|
||||||
|
# manual rebuild, which publishes the newest vX.Y.Z release on origin/main
|
||||||
|
# and the release the git tag stable marks
|
||||||
|
# Output: releases=<JSON list of {"sha", "version", "aliases"}> in GITHUB_OUTPUT; aliases are the
|
||||||
|
# moving tags a release takes besides <version> and <version>.<unix-ts>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SHA="$(git rev-parse HEAD)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
|
# shellcheck source=scripts/build/base.sh
|
||||||
if [[ -z "${V_TAG}" ]]; then
|
source "${SCRIPT_DIR}/../../build/base.sh"
|
||||||
echo "No version tag found for ${SHA}. Skipping publish."
|
|
||||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
: "${WORKFLOW_RUN_SHA?WORKFLOW_RUN_SHA must be set, empty for a rebuild}"
|
||||||
exit 0
|
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||||
|
|
||||||
|
RELEASE_TAG='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||||
|
|
||||||
|
release_at() {
|
||||||
|
local listed tag
|
||||||
|
listed="$(git tag --points-at "$1" --sort=-v:refname)"
|
||||||
|
tag="$(grep -m1 -E "${RELEASE_TAG}" <<< "${listed}" || true)"
|
||||||
|
echo "${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
newest_of() {
|
||||||
|
local prefix="$1" version
|
||||||
|
for version in "${RELEASES[@]}"; do
|
||||||
|
if [[ "${version}." == "${prefix}."* ]]; then
|
||||||
|
echo "${version}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
aliases_of() {
|
||||||
|
local version="$1" prefix
|
||||||
|
local -a aliases=()
|
||||||
|
if [[ "${version}" == "${RELEASES[0]}" ]]; then
|
||||||
|
aliases+=(latest)
|
||||||
|
fi
|
||||||
|
while read -r prefix; do
|
||||||
|
if [[ "${prefix}" != "${version}" && "$(newest_of "${prefix}")" == "${version}" ]]; then
|
||||||
|
aliases+=("${prefix}")
|
||||||
|
fi
|
||||||
|
done < <(version_prefixes "${version}")
|
||||||
|
if [[ "${version}" == "${STABLE}" ]]; then
|
||||||
|
aliases+=(stable)
|
||||||
|
fi
|
||||||
|
echo "${aliases[*]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
listed="$(git tag --merged origin/main --sort=-v:refname)"
|
||||||
|
mapfile -t tags < <(grep -E "${RELEASE_TAG}" <<< "${listed}")
|
||||||
|
RELEASES=("${tags[@]#v}")
|
||||||
|
|
||||||
|
STABLE=""
|
||||||
|
stable_sha="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' || true)"
|
||||||
|
if [[ -n "${stable_sha}" ]]; then
|
||||||
|
STABLE="$(release_at "${stable_sha}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${V_TAG#v}"
|
targets=()
|
||||||
|
if [[ -n "${WORKFLOW_RUN_SHA}" ]]; then
|
||||||
|
version="$(release_at "${WORKFLOW_RUN_SHA}")"
|
||||||
|
if ! git merge-base --is-ancestor "${WORKFLOW_RUN_SHA}" origin/main; then
|
||||||
|
echo "${WORKFLOW_RUN_SHA} is not on origin/main. Skipping publish."
|
||||||
|
elif [[ -z "${version}" ]]; then
|
||||||
|
echo "No vX.Y.Z tag found for ${WORKFLOW_RUN_SHA}. Skipping publish."
|
||||||
|
else
|
||||||
|
targets+=("${version}")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
((${#RELEASES[@]} > 0)) || { echo "No vX.Y.Z tag is reachable from origin/main." >&2; exit 1; }
|
||||||
|
targets+=("${RELEASES[0]}")
|
||||||
|
if [[ -z "${STABLE}" ]]; then
|
||||||
|
echo "The git tag stable marks no vX.Y.Z release; the stable image stays where it is."
|
||||||
|
elif [[ "${STABLE}" != "${RELEASES[0]}" ]]; then
|
||||||
|
targets+=("${STABLE}")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
STABLE_SHA="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' 2>/dev/null || true)"
|
json=""
|
||||||
IS_STABLE=false
|
for version in "${targets[@]}"; do
|
||||||
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
sha="$(git rev-parse "v${version}^{commit}")"
|
||||||
|
moving="$(aliases_of "${version}")"
|
||||||
|
echo "Publishing ${version} at ${sha}, moving: ${moving}"
|
||||||
|
json+="${json:+,}{\"sha\":\"${sha}\",\"version\":\"${version}\",\"aliases\":\"${moving}\"}"
|
||||||
|
done
|
||||||
|
|
||||||
{
|
echo "releases=[${json}]" >> "${GITHUB_OUTPUT}"
|
||||||
echo "should_publish=true"
|
|
||||||
echo "version=${VERSION}"
|
|
||||||
echo "is_stable=${IS_STABLE}"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${OWNER:?OWNER must be set}"
|
||||||
|
: "${VERSION:?VERSION must be set}"
|
||||||
|
: "${ALIASES?ALIASES must be set, empty for none}"
|
||||||
|
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
|
||||||
|
|
||||||
|
bash scripts/build/image.sh \
|
||||||
|
--publish \
|
||||||
|
--registry ghcr.io \
|
||||||
|
--owner "${OWNER}" \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--aliases "${ALIASES}" \
|
||||||
|
--merge
|
||||||
@@ -3,6 +3,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
: "${OWNER:?OWNER must be set}"
|
: "${OWNER:?OWNER must be set}"
|
||||||
: "${VERSION:?VERSION 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}"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo ">>> Running RUFF lint on src and tests (local, no container)"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
if ! command -v ruff >/dev/null 2>&1; then
|
||||||
|
echo "ruff is not installed or not on PATH." >&2
|
||||||
|
echo "Install it with: pip install ruff" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
ruff --version
|
||||||
|
ruff check src tests
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Top-level pkgmgr package.
|
Top-level pkgmgr package.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# expose subpackages for patch() / resolve_name() friendliness
|
# expose subpackages for patch() / resolve_name() friendliness
|
||||||
from . import release as release # noqa: F401
|
from . import release as release
|
||||||
|
|
||||||
__all__ = ["release"]
|
__all__ = ["release"]
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
||||||
TEMPLATE_FILENAME = "000-template.md"
|
TEMPLATE_FILENAME = "000-template.md"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -101,10 +102,8 @@ def run_archive(
|
|||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
for path, _title in archived:
|
for path, _title in archived:
|
||||||
try:
|
with contextlib.suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ArchivePlan(
|
return ArchivePlan(
|
||||||
archived=archived,
|
archived=archived,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Public API for branch actions.
|
Public API for branch actions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .open_branch import open_branch
|
|
||||||
from .close_branch import close_branch
|
from .close_branch import close_branch
|
||||||
from .drop_branch import drop_branch
|
from .drop_branch import drop_branch
|
||||||
|
from .gc import branch_purge, cleanup
|
||||||
|
from .open_branch import open_branch
|
||||||
|
from .tree import branch_tree, refresh_tree_entry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"open_branch",
|
"branch_purge",
|
||||||
|
"branch_tree",
|
||||||
|
"cleanup",
|
||||||
"close_branch",
|
"close_branch",
|
||||||
"drop_branch",
|
"drop_branch",
|
||||||
|
"open_branch",
|
||||||
|
"refresh_tree_entry",
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
checkout,
|
checkout,
|
||||||
@@ -14,12 +10,12 @@ from pkgmgr.core.git.commands import (
|
|||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import resolve_base_branch
|
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||||
|
|
||||||
|
|
||||||
def close_branch(
|
def close_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
delete_local_branch,
|
delete_local_branch,
|
||||||
delete_remote_branch,
|
delete_remote_branch,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import resolve_base_branch
|
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||||
|
|
||||||
|
|
||||||
def drop_branch(
|
def drop_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
return facts_with_token(slugs, token)
|
||||||
|
|
||||||
|
|
||||||
|
def facts_with_token(slugs: list[tuple[str, str]], token: str) -> ForgeFacts:
|
||||||
|
"""
|
||||||
|
Fetch the pull-request and protection facts of GitHub repositories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slugs: (owner, name) of each repository.
|
||||||
|
token: the GitHub token sent as bearer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The facts, or facts whose unavailable names the failing repository.
|
||||||
|
"""
|
||||||
|
facts = ForgeFacts()
|
||||||
|
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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
path: a path as git or a plan names it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when path is absolute and already its own realpath: no symbolic
|
||||||
|
link and no `..` component that the kernel could resolve elsewhere.
|
||||||
|
"""
|
||||||
|
return os.path.isabs(path) and os.path.realpath(path) == path
|
||||||
|
|
||||||
|
|
||||||
|
def remove_tree(path: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete a directory tree without following a symbolic link anywhere on
|
||||||
|
the way to it, even one swapped in after the caller checked the path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: absolute, canonical directory to delete.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: a component of path is a symbolic link or missing, or the
|
||||||
|
deletion failed.
|
||||||
|
"""
|
||||||
|
parent, name = os.path.split(path)
|
||||||
|
fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
for component in [c for c in parent.split(os.sep) if c]:
|
||||||
|
child = os.open(component, DIR_FLAGS, dir_fd=fd)
|
||||||
|
os.close(fd)
|
||||||
|
fd = child
|
||||||
|
# Trip-wire: rmtree(dir_fd=) needs Python 3.11; the fd link pins the
|
||||||
|
# opened parent on every version, and rmtree refuses a symlinked name.
|
||||||
|
shutil.rmtree(f"/proc/self/fd/{fd}/{name}")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
checkout,
|
checkout,
|
||||||
create_branch,
|
create_branch,
|
||||||
@@ -13,7 +11,7 @@ from pkgmgr.core.git.queries import resolve_base_branch
|
|||||||
|
|
||||||
|
|
||||||
def open_branch(
|
def open_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pkgmgr.actions.worktree import flow
|
||||||
|
from pkgmgr.core.credentials.store_keys import build_keyring_key
|
||||||
|
from pkgmgr.core.git.errors import GitBaseError
|
||||||
|
|
||||||
|
from . import gc
|
||||||
|
from .gc_forge import GITHUB_HOST, ForgeFacts, facts_with_token, github_slugs
|
||||||
|
|
||||||
|
WORKTREES_ROOT = os.path.join(
|
||||||
|
os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
|
||||||
|
"worktrees",
|
||||||
|
)
|
||||||
|
WORKTREE_DEPTH = 6
|
||||||
|
CACHE_TTL = 600
|
||||||
|
SLUG = re.compile(r"^(?!\.\.?$)[\w.-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> float:
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(stamp: float) -> str:
|
||||||
|
return datetime.fromtimestamp(stamp, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def tree_file() -> str:
|
||||||
|
state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
|
||||||
|
return os.environ.get("PKGMGR_TREE_FILE") or os.path.join(
|
||||||
|
state, "pkgmgr", "tree", "branch-tree.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_path(owner: str, name: str) -> str:
|
||||||
|
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
||||||
|
return os.path.join(base, "pkgmgr", "forge", owner, f"{name}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def secret_service_token() -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
Read the GitHub token from the Secret Service without unlocking it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(token, None), or (None, why no token is available).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import secretstorage
|
||||||
|
except ImportError:
|
||||||
|
return None, "secretstorage not installed"
|
||||||
|
key = build_keyring_key("github", GITHUB_HOST, None)
|
||||||
|
try:
|
||||||
|
connection = secretstorage.dbus_init()
|
||||||
|
try:
|
||||||
|
items = list(
|
||||||
|
secretstorage.search_items(
|
||||||
|
connection, {"service": key.service, "username": key.username}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unlocked = [item for item in items if not item.is_locked()]
|
||||||
|
if not items:
|
||||||
|
return None, "no GitHub token in the Secret Service"
|
||||||
|
if not unlocked:
|
||||||
|
return None, "Secret Service locked"
|
||||||
|
return unlocked[0].get_secret().decode(), None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
except (secretstorage.exceptions.SecretStorageException, OSError) as exc:
|
||||||
|
return None, f"Secret Service unavailable: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _locked(path: str) -> Iterator[None]:
|
||||||
|
folder, name = os.path.split(os.path.abspath(path))
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
with open(os.path.join(folder, f".{name}.lock"), "w", encoding="utf-8") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _replace(path: str, text: str) -> None:
|
||||||
|
folder, name = os.path.split(os.path.abspath(path))
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=folder, prefix=f".{name}.")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(text)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: str, text: str) -> None:
|
||||||
|
with _locked(path):
|
||||||
|
_replace(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(path: str) -> ForgeFacts | None:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
if now() - data["fetched_at"] >= CACHE_TTL:
|
||||||
|
return None
|
||||||
|
return ForgeFacts(
|
||||||
|
merged_heads=data["merged_heads"],
|
||||||
|
open_heads=data["open_heads"],
|
||||||
|
protected=set(data["protected"]),
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _store(path: str, facts: ForgeFacts) -> None:
|
||||||
|
data = {
|
||||||
|
"fetched_at": now(),
|
||||||
|
"merged_heads": facts.merged_heads,
|
||||||
|
"open_heads": facts.open_heads,
|
||||||
|
"protected": sorted(facts.protected),
|
||||||
|
}
|
||||||
|
_write(path, json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable(reason: str | None) -> tuple[ForgeFacts, str]:
|
||||||
|
return ForgeFacts(unavailable=reason), f"unavailable: {reason}"
|
||||||
|
|
||||||
|
|
||||||
|
def forge_facts(
|
||||||
|
urls: dict[str, str], token: str | None, reason: str | None
|
||||||
|
) -> tuple[ForgeFacts, str]:
|
||||||
|
"""
|
||||||
|
Forge facts of one repository from the cache, or live once the cache is stale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: remote name -> URL.
|
||||||
|
token: GitHub token from the Secret Service, or None.
|
||||||
|
reason: why the token is None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The facts and their source: none, cached, live or unavailable: <why>.
|
||||||
|
"""
|
||||||
|
slugs = github_slugs(urls)
|
||||||
|
if not slugs:
|
||||||
|
return ForgeFacts(), "none"
|
||||||
|
invalid = [f"{o}/{n}" for o, n in slugs if not (SLUG.match(o) and SLUG.match(n))]
|
||||||
|
if invalid:
|
||||||
|
return _unavailable(f"invalid GitHub slug {invalid[0]}")
|
||||||
|
merged, label = ForgeFacts(), "cached"
|
||||||
|
for owner, name in slugs:
|
||||||
|
path = _cache_path(owner, name)
|
||||||
|
facts = _cached(path)
|
||||||
|
if facts is None:
|
||||||
|
if token is None:
|
||||||
|
return _unavailable(reason)
|
||||||
|
facts = facts_with_token([(owner, name)], token)
|
||||||
|
if facts.unavailable:
|
||||||
|
return _unavailable(facts.unavailable)
|
||||||
|
try:
|
||||||
|
_store(path, facts)
|
||||||
|
label = "live" if label == "cached" else label
|
||||||
|
except OSError as exc:
|
||||||
|
label = f"live: cache not written ({exc})"
|
||||||
|
merged.merged_heads.update(facts.merged_heads)
|
||||||
|
merged.open_heads.update(facts.open_heads)
|
||||||
|
merged.protected |= facts.protected
|
||||||
|
return merged, label
|
||||||
|
|
||||||
|
|
||||||
|
def _missing(root: str, known: bool, scanned_at: str) -> dict:
|
||||||
|
return {
|
||||||
|
"path": root,
|
||||||
|
"remote": None,
|
||||||
|
"default": None,
|
||||||
|
"missing": True,
|
||||||
|
"known": known,
|
||||||
|
"forge": None,
|
||||||
|
"scanned_at": scanned_at,
|
||||||
|
"warnings": [],
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_dir(
|
||||||
|
root: str, registrations: gc.Registrations, worktree: str | None
|
||||||
|
) -> str | None:
|
||||||
|
if worktree is None:
|
||||||
|
return None
|
||||||
|
if worktree == root:
|
||||||
|
return os.path.dirname(registrations[0])
|
||||||
|
return gc._linked_meta(registrations, worktree)
|
||||||
|
|
||||||
|
|
||||||
|
def _flow(git_dir: str | None, item: dict, warnings: list[str]) -> dict | None:
|
||||||
|
found = flow.read_flow(git_dir) if git_dir else None
|
||||||
|
if found is None or found.source == "none":
|
||||||
|
return None
|
||||||
|
warnings += [
|
||||||
|
f"flow of {item['worktree']}: {warning}"
|
||||||
|
for warning in found.warnings + flow.mismatch(found, item["branch"])
|
||||||
|
]
|
||||||
|
return {"pull": found.pull, "push": found.push, "source": found.source}
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(
|
||||||
|
root: str,
|
||||||
|
known: bool,
|
||||||
|
token: str | None,
|
||||||
|
reason: str | None,
|
||||||
|
containers: list[gc.Container] | None,
|
||||||
|
host_ns: bool,
|
||||||
|
) -> dict:
|
||||||
|
scanned_at = _iso(now())
|
||||||
|
entry = _missing(root, known, scanned_at)
|
||||||
|
if not os.path.exists(os.path.join(root, ".git")):
|
||||||
|
return entry
|
||||||
|
sources: list[str] = []
|
||||||
|
|
||||||
|
def source(urls: dict[str, str]) -> ForgeFacts:
|
||||||
|
facts, label = forge_facts(urls, token, reason)
|
||||||
|
sources.append(label)
|
||||||
|
return facts
|
||||||
|
|
||||||
|
entry["missing"] = False
|
||||||
|
try:
|
||||||
|
repo = gc.plan_repo(
|
||||||
|
root,
|
||||||
|
containers,
|
||||||
|
host_ns,
|
||||||
|
merged_into=[],
|
||||||
|
sizes=False,
|
||||||
|
min_age=None,
|
||||||
|
scope="all",
|
||||||
|
activity=True,
|
||||||
|
hooks={},
|
||||||
|
fetch=False,
|
||||||
|
forge_source=source,
|
||||||
|
)
|
||||||
|
registrations = gc._registrations(root)
|
||||||
|
except GitBaseError as exc:
|
||||||
|
entry["warnings"] = [f"scan failed: {str(exc).strip().splitlines()[0]}"]
|
||||||
|
return entry
|
||||||
|
owner = next((r for r in repo.remotes if repo.default.startswith(f"{r}/")), None)
|
||||||
|
items = [asdict(item) for item in repo.items]
|
||||||
|
for item in items:
|
||||||
|
git_dir = _git_dir(root, registrations, item["worktree"])
|
||||||
|
item["flow"] = _flow(git_dir, item, repo.warnings)
|
||||||
|
entry.update(
|
||||||
|
remote=repo.urls[repo.remotes[0]] if repo.remotes else None,
|
||||||
|
default=repo.default.removeprefix(f"{owner}/") if owner else repo.default,
|
||||||
|
forge=sources[0],
|
||||||
|
warnings=repo.warnings,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_root(checkout: str) -> str | None:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(checkout, ".git"), encoding="utf-8") as fh:
|
||||||
|
gitdir = fh.read().strip().removeprefix("gitdir: ")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
parent = os.path.dirname(os.path.realpath(os.path.join(checkout, gitdir)))
|
||||||
|
if os.path.basename(parent) != "worktrees":
|
||||||
|
return None
|
||||||
|
common = os.path.dirname(parent)
|
||||||
|
return os.path.dirname(common) if os.path.basename(common) == ".git" else common
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_owners(worktrees_root: str) -> list[str]:
|
||||||
|
owners: set[str] = set()
|
||||||
|
base = worktrees_root.rstrip("/").count("/")
|
||||||
|
for path, dirs, files in os.walk(worktrees_root):
|
||||||
|
dirs.sort()
|
||||||
|
owner = _owner_root(path) if ".git" in files else None
|
||||||
|
if owner:
|
||||||
|
owners.add(owner)
|
||||||
|
if (
|
||||||
|
".git" in files
|
||||||
|
or ".git" in dirs
|
||||||
|
or path.count("/") - base >= WORKTREE_DEPTH
|
||||||
|
):
|
||||||
|
dirs.clear()
|
||||||
|
return sorted(owners)
|
||||||
|
|
||||||
|
|
||||||
|
def branch_tree(
|
||||||
|
repo_dirs: list[str], *, fmt: str, output: str | None, worktrees_root: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Print or write the branch and worktree state of every repository, without fetching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_dirs: directories of the repositories pkgmgr knows.
|
||||||
|
fmt: json or yaml.
|
||||||
|
output: file to write atomically; None prints to stdout.
|
||||||
|
worktrees_root: shared worktree root whose unknown owners are added.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
0 on success, 1 when the output could not be written.
|
||||||
|
"""
|
||||||
|
known = list(dict.fromkeys(os.path.realpath(d) for d in repo_dirs))
|
||||||
|
orphans = [d for d in _shared_owners(worktrees_root) if d not in known]
|
||||||
|
token, reason = secret_service_token()
|
||||||
|
host_ns = gc.host_pid_namespace()
|
||||||
|
containers = gc.list_compose_containers()
|
||||||
|
data = {
|
||||||
|
"generated_at": _iso(now()),
|
||||||
|
"host_pid_namespace": host_ns,
|
||||||
|
"delete_ready": token is not None,
|
||||||
|
"delete_reason": reason,
|
||||||
|
"repos": [
|
||||||
|
_scan(root, root in known, token, reason, containers, host_ns)
|
||||||
|
for root in known + orphans
|
||||||
|
],
|
||||||
|
}
|
||||||
|
text = (
|
||||||
|
yaml.safe_dump(data, sort_keys=False)
|
||||||
|
if fmt == "yaml"
|
||||||
|
else json.dumps(data, indent=2) + "\n"
|
||||||
|
)
|
||||||
|
if output is None:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
_write(output, text)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Cannot write {output}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_tree_entry(repo_root: str) -> None:
|
||||||
|
"""
|
||||||
|
Rescan one repository into the tree file, if the file exists and holds it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: main worktree root of the repository.
|
||||||
|
|
||||||
|
Skipped outside the host PID namespace and when the file's entry is newer.
|
||||||
|
"""
|
||||||
|
path = tree_file()
|
||||||
|
if not os.path.isfile(path) or not gc.host_pid_namespace():
|
||||||
|
return
|
||||||
|
root = os.path.realpath(repo_root)
|
||||||
|
token, reason = secret_service_token()
|
||||||
|
fresh = _scan(root, True, token, reason, gc.list_compose_containers(), True)
|
||||||
|
with _locked(path):
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
index = next(
|
||||||
|
(i for i, entry in enumerate(data["repos"]) if entry["path"] == root), None
|
||||||
|
)
|
||||||
|
if index is None or data["repos"][index]["scanned_at"] > fresh["scanned_at"]:
|
||||||
|
return
|
||||||
|
fresh["known"] = data["repos"][index]["known"]
|
||||||
|
data["repos"][index] = fresh
|
||||||
|
_replace(path, json.dumps(data, indent=2) + "\n")
|
||||||
@@ -1,24 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helpers to generate changelog information from Git history.
|
Helpers to generate changelog information from Git history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import (
|
from pkgmgr.core.git.queries import (
|
||||||
get_changelog,
|
|
||||||
GitChangelogQueryError,
|
GitChangelogQueryError,
|
||||||
|
get_changelog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_changelog(
|
def generate_changelog(
|
||||||
cwd: str,
|
cwd: str,
|
||||||
from_ref: Optional[str] = None,
|
from_ref: str | None = None,
|
||||||
to_ref: Optional[str] = None,
|
to_ref: str | None = None,
|
||||||
include_merges: bool = False,
|
include_merges: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class CodeScanningError(RuntimeError):
|
class CodeScanningError(RuntimeError):
|
||||||
@@ -27,14 +27,14 @@ class CodeScanningResult:
|
|||||||
repo: str
|
repo: str
|
||||||
output_dir: str
|
output_dir: str
|
||||||
alert_count: int
|
alert_count: int
|
||||||
files: List[str] = field(default_factory=list)
|
files: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _gh(args: List[str]) -> "subprocess.CompletedProcess[str]":
|
def _gh(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
return subprocess.run(["gh", *args], capture_output=True, text=True)
|
return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_repo(repo: Optional[str]) -> str:
|
def _resolve_repo(repo: str | None) -> str:
|
||||||
if repo:
|
if repo:
|
||||||
return repo
|
return repo
|
||||||
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
|
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
|
||||||
@@ -48,7 +48,7 @@ def _resolve_repo(repo: Optional[str]) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json(endpoint: str, params: Optional[List[str]] = None) -> Any:
|
def _fetch_json(endpoint: str, params: list[str] | None = None) -> Any:
|
||||||
args = ["api", endpoint, "--paginate"]
|
args = ["api", endpoint, "--paginate"]
|
||||||
for param in params or []:
|
for param in params or []:
|
||||||
args += ["-f", param]
|
args += ["-f", param]
|
||||||
@@ -74,7 +74,7 @@ def _alert_row(alert: dict) -> str:
|
|||||||
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
|
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(repo: str, generated_at: str, alerts: List[dict]) -> str:
|
def _build_summary(repo: str, generated_at: str, alerts: list[dict]) -> str:
|
||||||
by_severity: Counter = Counter()
|
by_severity: Counter = Counter()
|
||||||
by_state: Counter = Counter()
|
by_state: Counter = Counter()
|
||||||
by_rule: Counter = Counter()
|
by_rule: Counter = Counter()
|
||||||
@@ -112,9 +112,9 @@ def _build_summary(repo: str, generated_at: str, alerts: List[dict]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def download_code_scanning(
|
def download_code_scanning(
|
||||||
repo: Optional[str] = None,
|
repo: str | None = None,
|
||||||
output_dir: Optional[str] = None,
|
output_dir: str | None = None,
|
||||||
state: Optional[str] = None,
|
state: str | None = None,
|
||||||
) -> CodeScanningResult:
|
) -> CodeScanningResult:
|
||||||
if not shutil.which("gh"):
|
if not shutil.which("gh"):
|
||||||
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
|
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
|
||||||
@@ -123,18 +123,18 @@ def download_code_scanning(
|
|||||||
repo_name = repo.rstrip("/").split("/")[-1]
|
repo_name = repo.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
|
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
|
||||||
output_dir = os.path.abspath(os.path.expanduser(output_dir))
|
output_dir = os.path.abspath(os.path.expanduser(output_dir))
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
generated_at = datetime.now().isoformat(timespec="seconds")
|
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
alerts = _fetch_json(
|
alerts = _fetch_json(
|
||||||
f"repos/{repo}/code-scanning/alerts",
|
f"repos/{repo}/code-scanning/alerts",
|
||||||
params=[f"state={state}"] if state else None,
|
params=[f"state={state}"] if state else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
files: List[str] = []
|
files: list[str] = []
|
||||||
|
|
||||||
def _write(name: str, content: str) -> None:
|
def _write(name: str, content: str) -> None:
|
||||||
path = os.path.join(output_dir, name)
|
path = os.path.join(output_dir, name)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import yaml
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from pkgmgr.core.config.save import save_user_config
|
from pkgmgr.core.config.save import save_user_config
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ def interactive_add(config, USER_CONFIG_PATH: str):
|
|||||||
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
||||||
if confirm == "y":
|
if confirm == "y":
|
||||||
if os.path.exists(USER_CONFIG_PATH):
|
if os.path.exists(USER_CONFIG_PATH):
|
||||||
with open(USER_CONFIG_PATH, "r") as f:
|
with open(USER_CONFIG_PATH) as f:
|
||||||
user_config = yaml.safe_load(f) or {}
|
user_config = yaml.safe_load(f) or {}
|
||||||
else:
|
else:
|
||||||
user_config = {"repositories": []}
|
user_config = {"repositories": []}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Initialize user configuration by scanning the repositories base directory.
|
Initialize user configuration by scanning the repositories base directory.
|
||||||
|
|
||||||
@@ -23,7 +20,7 @@ For each discovered repository, the function:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from pkgmgr.core.command.alias import generate_alias
|
from pkgmgr.core.command.alias import generate_alias
|
||||||
from pkgmgr.core.config.save import save_user_config
|
from pkgmgr.core.config.save import save_user_config
|
||||||
@@ -31,8 +28,8 @@ from pkgmgr.core.git.queries import get_latest_commit
|
|||||||
|
|
||||||
|
|
||||||
def config_init(
|
def config_init(
|
||||||
user_config: Dict[str, Any],
|
user_config: dict[str, Any],
|
||||||
defaults_config: Dict[str, Any],
|
defaults_config: dict[str, Any],
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
user_config_path: str,
|
user_config_path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -55,7 +52,7 @@ def config_init(
|
|||||||
|
|
||||||
print("[INIT] Scanning repository base directory:")
|
print("[INIT] Scanning repository base directory:")
|
||||||
print(f" {repositories_base_dir}")
|
print(f" {repositories_base_dir}")
|
||||||
print("")
|
print()
|
||||||
|
|
||||||
if not os.path.isdir(repositories_base_dir):
|
if not os.path.isdir(repositories_base_dir):
|
||||||
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
|
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
|
||||||
@@ -129,7 +126,7 @@ def config_init(
|
|||||||
"[WARN] Could not read commit (not a git repo or no commits)."
|
"[WARN] Could not read commit (not a git repo or no commits)."
|
||||||
)
|
)
|
||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"account": account,
|
"account": account,
|
||||||
"repository": repo_name,
|
"repository": repo_name,
|
||||||
@@ -153,7 +150,7 @@ def config_init(
|
|||||||
|
|
||||||
new_entries.append(entry)
|
new_entries.append(entry)
|
||||||
|
|
||||||
print("") # blank line between accounts
|
print() # blank line between accounts
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Summary
|
# Summary
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from pkgmgr.core.config.load import load_config
|
from pkgmgr.core.config.load import load_config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
from .render import render_text
|
||||||
|
from .report import disk_report
|
||||||
|
|
||||||
|
__all__ = ["disk_report", "render_text"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import _inside
|
||||||
|
|
||||||
|
|
||||||
|
def _mount(path: str) -> str:
|
||||||
|
path = os.path.realpath(path)
|
||||||
|
while not os.path.ismount(path):
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def balance(paths: list[str], items: list[tuple[str, int]]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Set what the report attributes against each filesystem's capacity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: the roots and repository roots; each one's filesystem gets a row.
|
||||||
|
items: (path, allocated bytes) of every listed entry; an entry inside
|
||||||
|
another listed entry is counted only through the outer one.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Per filesystem (grouped by st_dev): its mount point, total and free
|
||||||
|
bytes (as available to unprivileged users) and the attributed bytes.
|
||||||
|
"""
|
||||||
|
filesystems: dict[int, dict] = {}
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
dev = os.stat(path).st_dev
|
||||||
|
if dev in filesystems:
|
||||||
|
continue
|
||||||
|
vfs = os.statvfs(path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
filesystems[dev] = {
|
||||||
|
"filesystem": _mount(path),
|
||||||
|
"total_bytes": vfs.f_blocks * vfs.f_frsize,
|
||||||
|
"free_bytes": vfs.f_bavail * vfs.f_frsize,
|
||||||
|
"attributed_bytes": 0,
|
||||||
|
}
|
||||||
|
kept: list[str] = []
|
||||||
|
for path, size in sorted((os.path.realpath(p), s) for p, s in items):
|
||||||
|
if any(_inside(path, outer) for outer in kept):
|
||||||
|
continue
|
||||||
|
kept.append(path)
|
||||||
|
try:
|
||||||
|
dev = os.lstat(path).st_dev
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if dev in filesystems:
|
||||||
|
filesystems[dev]["attributed_bytes"] += size
|
||||||
|
return sorted(filesystems.values(), key=lambda fs: fs["filesystem"])
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .walk import BLOCK
|
||||||
|
|
||||||
|
ELF_MAGIC = b"\x7fELF"
|
||||||
|
ELF_HEADER = 18
|
||||||
|
ET_CORE = 4
|
||||||
|
BYTE_ORDER = {1: "little", 2: "big"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_core_dump(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
head = os.read(fd, ELF_HEADER)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
order = BYTE_ORDER.get(head[5]) if len(head) == ELF_HEADER else None
|
||||||
|
return (
|
||||||
|
head[:4] == ELF_MAGIC
|
||||||
|
and order is not None
|
||||||
|
and int.from_bytes(head[16:18], order) == ET_CORE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def core_dumps(candidates: list[str]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Keep the candidates whose ELF header says ET_CORE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
candidates: paths of regular files named like core dumps.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One entry per dump with its allocated and apparent size and mtime,
|
||||||
|
biggest first.
|
||||||
|
"""
|
||||||
|
dumps: dict[str, dict] = {}
|
||||||
|
for path in candidates:
|
||||||
|
real = os.path.realpath(path)
|
||||||
|
if real in dumps or not is_core_dump(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
dumps[real] = {
|
||||||
|
"path": path,
|
||||||
|
"size_bytes": st.st_blocks * BLOCK,
|
||||||
|
"apparent_bytes": st.st_size,
|
||||||
|
"mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
return sorted(dumps.values(), key=lambda d: (-d["size_bytes"], d["path"]))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import _describe, _docker
|
||||||
|
|
||||||
|
DF_ARGS = ["system", "df", "--format", "{{json .}}"]
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: object) -> int | None:
|
||||||
|
text = str(value)
|
||||||
|
return int(text) if text.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def docker_usage() -> tuple[list[dict] | None, str | None]:
|
||||||
|
"""
|
||||||
|
Read `docker system df` per type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The rows (type, total and active counts, size and reclaimable as docker
|
||||||
|
prints them) and None, or None and a warning when docker is missing,
|
||||||
|
fails or prints something else than JSON lines.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = _docker(DF_ARGS)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None, "docker unavailable: not installed"
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
return None, f"docker unavailable: {_describe(exc)}"
|
||||||
|
rows: list[dict] = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
data = None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, "docker system df: output is not JSON lines"
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"type": data.get("Type"),
|
||||||
|
"total": _count(data.get("TotalCount")),
|
||||||
|
"active": _count(data.get("Active")),
|
||||||
|
"size": data.get("Size"),
|
||||||
|
"reclaimable": data.get("Reclaimable"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows, None
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import _describe, _git, _has_submodules
|
||||||
|
from pkgmgr.core.git.errors import GitBaseError
|
||||||
|
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
|
||||||
|
from pkgmgr.core.git.run import run
|
||||||
|
|
||||||
|
REF_NAMESPACES = ("refs/heads", "refs/remotes", "refs/tags")
|
||||||
|
PEELED = "%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)"
|
||||||
|
UNMERGED = "unmerged"
|
||||||
|
DELETED = "deleted"
|
||||||
|
DIRECTORY = "directory"
|
||||||
|
SPECIAL = "special"
|
||||||
|
UNHASHABLE = {
|
||||||
|
DIRECTORY: "untracked directory or gitlink",
|
||||||
|
SPECIAL: "not a regular file",
|
||||||
|
}
|
||||||
|
MAX_REASONS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _tips(repo: str) -> list[str]:
|
||||||
|
out = _git(["for-each-ref", f"--format={PEELED}", *REF_NAMESPACES], repo)
|
||||||
|
return sorted(set(out.split()))
|
||||||
|
|
||||||
|
|
||||||
|
def _status_entries(tree: str) -> list[tuple[str, str | None]]:
|
||||||
|
raw = _git(
|
||||||
|
[
|
||||||
|
"status",
|
||||||
|
"--porcelain=v2",
|
||||||
|
"-z",
|
||||||
|
"--untracked-files=all",
|
||||||
|
"--no-renames",
|
||||||
|
],
|
||||||
|
tree,
|
||||||
|
)
|
||||||
|
entries: list[tuple[str, str | None]] = []
|
||||||
|
for record in raw.split("\0"):
|
||||||
|
kind = record[:1]
|
||||||
|
if kind == "1":
|
||||||
|
parts = record.split(" ", 8)
|
||||||
|
entries.append((parts[8], parts[7]))
|
||||||
|
elif kind == "u":
|
||||||
|
entries.append((record.split(" ", 10)[10], UNMERGED))
|
||||||
|
elif kind == "?":
|
||||||
|
entries.append((record[2:], None))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _hashes(tree: str, paths: list[str]) -> dict[str, str]:
|
||||||
|
hashes: dict[str, str] = {}
|
||||||
|
files: list[str] = []
|
||||||
|
for path in paths:
|
||||||
|
full = os.path.join(tree, path)
|
||||||
|
try:
|
||||||
|
mode = os.lstat(full).st_mode
|
||||||
|
except FileNotFoundError:
|
||||||
|
hashes[path] = DELETED
|
||||||
|
continue
|
||||||
|
if stat.S_ISREG(mode):
|
||||||
|
files.append(path)
|
||||||
|
elif stat.S_ISLNK(mode):
|
||||||
|
hashes[path] = run(
|
||||||
|
["hash-object", "-t", "blob", "--no-filters", "--stdin"],
|
||||||
|
cwd=tree,
|
||||||
|
scan=True,
|
||||||
|
input_text=os.readlink(full),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
hashes[path] = DIRECTORY if stat.S_ISDIR(mode) else SPECIAL
|
||||||
|
if files:
|
||||||
|
out = run(
|
||||||
|
["hash-object", "--no-filters", "--stdin-paths"],
|
||||||
|
cwd=tree,
|
||||||
|
scan=True,
|
||||||
|
input_text="".join(f"{path}\n" for path in files),
|
||||||
|
)
|
||||||
|
hashes.update(zip(files, out.split("\n")))
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def _absent(
|
||||||
|
repo: str, tips: list[str], wanted: list[tuple[str, set[str]]]
|
||||||
|
) -> list[str]:
|
||||||
|
query = "".join(f"{tip}:{path}\n" for path, _ in wanted for tip in tips)
|
||||||
|
if not query:
|
||||||
|
return [path for path, _ in wanted]
|
||||||
|
found = run(
|
||||||
|
["cat-file", "--batch-check=%(objectname)"],
|
||||||
|
cwd=repo,
|
||||||
|
scan=True,
|
||||||
|
input_text=query,
|
||||||
|
).split("\n")
|
||||||
|
width = len(tips)
|
||||||
|
return [
|
||||||
|
path
|
||||||
|
for index, (path, blobs) in enumerate(wanted)
|
||||||
|
if blobs - set(found[index * width : (index + 1) * width])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(repo: str, head: str | None) -> bool:
|
||||||
|
if not head:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
_git(["for-each-ref", "--count=1", f"--contains={head}", *REF_NAMESPACES], repo)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unpreserved(
|
||||||
|
repo: str, record: dict, tips: list[str]
|
||||||
|
) -> tuple[int | None, list[str]]:
|
||||||
|
tree = record["path"]
|
||||||
|
if _has_submodules(tree):
|
||||||
|
return None, ["submodules, whose config the scan cannot vet"]
|
||||||
|
unsafe = local_config(tree, FILTER_CONFIG)
|
||||||
|
if unsafe:
|
||||||
|
return None, [f"{', '.join(unsafe)} in the repository config"]
|
||||||
|
entries = _status_entries(tree)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if entries and record["branch"] is None and not _reachable(repo, record["head"]):
|
||||||
|
reasons.append("detached HEAD reachable from no ref")
|
||||||
|
hashable: list[tuple[str, str | None]] = []
|
||||||
|
for path, index_blob in entries:
|
||||||
|
if "\n" in path:
|
||||||
|
reasons.append(f"{path!r}: path with a newline")
|
||||||
|
elif index_blob == UNMERGED:
|
||||||
|
reasons.append(f"{path}: unmerged")
|
||||||
|
else:
|
||||||
|
hashable.append((path, index_blob))
|
||||||
|
hashes = _hashes(tree, [path for path, _ in hashable])
|
||||||
|
wanted: list[tuple[str, set[str]]] = []
|
||||||
|
for path, index_blob in hashable:
|
||||||
|
current = hashes[path]
|
||||||
|
if current in UNHASHABLE:
|
||||||
|
reasons.append(f"{path}: {UNHASHABLE[current]}")
|
||||||
|
continue
|
||||||
|
blobs = {current} - {DELETED}
|
||||||
|
if index_blob and set(index_blob) != {"0"}:
|
||||||
|
blobs.add(index_blob)
|
||||||
|
if blobs:
|
||||||
|
wanted.append((path, blobs))
|
||||||
|
reasons += [f"{path}: content in no ref" for path in _absent(repo, tips, wanted)]
|
||||||
|
return len(entries), reasons
|
||||||
|
|
||||||
|
|
||||||
|
def preserved(repo: str, records: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Decide for each dirty worktree whether all its work survives its deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: main worktree root of the repository.
|
||||||
|
records: its worktree records from list_worktrees.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One entry per dirty or unreadable worktree; `preserved` is true only
|
||||||
|
when every changed, staged and untracked file's content is a blob at
|
||||||
|
the same path of some branch, remote or tag tip, and a detached HEAD is
|
||||||
|
reachable from a ref. `reasons` lists what is not, capped.
|
||||||
|
"""
|
||||||
|
live = [r for r in records if not r["orphaned"] and not r["missing"]]
|
||||||
|
tips = _tips(repo) if live else []
|
||||||
|
results: list[dict] = []
|
||||||
|
for record in live:
|
||||||
|
try:
|
||||||
|
paths, reasons = _unpreserved(repo, record, tips)
|
||||||
|
except (GitBaseError, OSError, UnicodeError) as exc:
|
||||||
|
paths, reasons = None, [f"status failed: {_describe(exc)}"]
|
||||||
|
if paths == 0:
|
||||||
|
continue
|
||||||
|
extra = len(reasons) - MAX_REASONS
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"path": record["path"],
|
||||||
|
"branch": record["branch"],
|
||||||
|
"paths": paths,
|
||||||
|
"preserved": not reasons,
|
||||||
|
"reasons": reasons[:MAX_REASONS]
|
||||||
|
+ ([f"... {extra} more"] if extra > 0 else []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import human_size
|
||||||
|
|
||||||
|
UNITS = (("d", 86400), ("h", 3600), ("m", 60))
|
||||||
|
DOCKER_COLUMNS = ("type", "total", "active", "size", "reclaimable")
|
||||||
|
|
||||||
|
|
||||||
|
def _size(value: int | None) -> str:
|
||||||
|
return "-" if value is None else human_size(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _age(now: float, stamp: float | None) -> str:
|
||||||
|
if not stamp:
|
||||||
|
return "-"
|
||||||
|
seconds = max(0.0, now - stamp)
|
||||||
|
for unit, length in UNITS:
|
||||||
|
if seconds >= length:
|
||||||
|
return f"{seconds / length:.1f}{unit}"
|
||||||
|
return f"{seconds:.0f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def _section(title: str, headers: list[str], rows: list[list[str]]) -> list[str]:
|
||||||
|
if not rows:
|
||||||
|
return [f"== {title}", "(none)"]
|
||||||
|
widths = [max(len(cell) for cell in column) for column in zip(headers, *rows)]
|
||||||
|
return [f"== {title}"] + [
|
||||||
|
" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip()
|
||||||
|
for row in (headers, *rows)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _branch(tree: dict) -> str:
|
||||||
|
if tree["orphaned"]:
|
||||||
|
return "-"
|
||||||
|
return tree["branch"] or "(detached)"
|
||||||
|
|
||||||
|
|
||||||
|
def _state(tree: dict) -> str:
|
||||||
|
if tree["orphaned"]:
|
||||||
|
return "orphaned"
|
||||||
|
return "missing" if tree["missing"] else "linked"
|
||||||
|
|
||||||
|
|
||||||
|
def render_text(report: dict) -> str:
|
||||||
|
"""
|
||||||
|
Render a disk_report result as aligned text tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: the dict disk_report returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The tables, one section per report key, newline-joined.
|
||||||
|
"""
|
||||||
|
now = report["generated_at"]
|
||||||
|
lines = _section(
|
||||||
|
"worktrees",
|
||||||
|
["SIZE", "ACTIVE", "FOREIGN", "STATE", "BRANCH", "PATH"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
_size(t["size_bytes"]),
|
||||||
|
_age(now, t["last_activity"]),
|
||||||
|
str(t["foreign_entries"]),
|
||||||
|
_state(t),
|
||||||
|
_branch(t),
|
||||||
|
t["path"],
|
||||||
|
]
|
||||||
|
for t in report["worktrees"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
lines += _section(
|
||||||
|
"preserved (dirty worktrees)",
|
||||||
|
["PRESERVED", "PATHS", "BRANCH", "PATH", "REASONS"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"yes" if p["preserved"] else "no",
|
||||||
|
"-" if p["paths"] is None else str(p["paths"]),
|
||||||
|
p["branch"] or "(detached)",
|
||||||
|
p["path"],
|
||||||
|
"; ".join(p["reasons"]),
|
||||||
|
]
|
||||||
|
for p in report["preserved"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for root in report["roots"]:
|
||||||
|
lines += _section(
|
||||||
|
f"root {root['root']} (over {human_size(report['threshold_bytes'])})",
|
||||||
|
["SIZE", "AGE", "KIND", "PATH"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
_size(c["size_bytes"]),
|
||||||
|
_age(now, c["newest_mtime"]),
|
||||||
|
c["kind"],
|
||||||
|
c["path"],
|
||||||
|
]
|
||||||
|
for c in root["children"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
lines += _section(
|
||||||
|
"core dumps",
|
||||||
|
["SIZE", "APPARENT", "AGE", "PATH"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
_size(d["size_bytes"]),
|
||||||
|
_size(d["apparent_bytes"]),
|
||||||
|
_age(now, d["mtime"]),
|
||||||
|
d["path"],
|
||||||
|
]
|
||||||
|
for d in report["core_dumps"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if report["docker"] is not None:
|
||||||
|
lines += _section(
|
||||||
|
"docker",
|
||||||
|
["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"],
|
||||||
|
[[str(row[key]) for key in DOCKER_COLUMNS] for row in report["docker"]],
|
||||||
|
)
|
||||||
|
lines += _section(
|
||||||
|
"balance",
|
||||||
|
["FILESYSTEM", "TOTAL", "FREE", "ATTRIBUTED"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
fs["filesystem"],
|
||||||
|
_size(fs["total_bytes"]),
|
||||||
|
_size(fs["free_bytes"]),
|
||||||
|
_size(fs["attributed_bytes"]),
|
||||||
|
]
|
||||||
|
for fs in report["balance"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
lines += [f"WARN {warning}" for warning in report["warnings"]]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import _describe
|
||||||
|
from pkgmgr.core.git.errors import GitBaseError
|
||||||
|
|
||||||
|
from .balance import balance
|
||||||
|
from .core_dumps import core_dumps
|
||||||
|
from .docker_usage import docker_usage
|
||||||
|
from .preserved import preserved
|
||||||
|
from .roots import scan_root
|
||||||
|
from .worktrees import list_worktrees
|
||||||
|
|
||||||
|
|
||||||
|
def disk_report(
|
||||||
|
roots: list[str], repo_roots: list[str], threshold_bytes: int, docker: bool
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Inventory disk usage read-only: it deletes and writes nothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
roots: directories whose direct children are measured.
|
||||||
|
repo_roots: main worktree roots of the repositories whose linked
|
||||||
|
worktrees are listed (pkgmgr passes every managed repository).
|
||||||
|
threshold_bytes: list only root children larger than this.
|
||||||
|
docker: also read `docker system df`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JSON-serialisable dict. Sizes are allocated bytes (st_blocks * 512,
|
||||||
|
sparse files and hard links counted as stored), measured without
|
||||||
|
following symlinks or leaving the filesystem a walk starts on; times
|
||||||
|
are epoch seconds.
|
||||||
|
generated_at: when the scan started.
|
||||||
|
threshold_bytes: as given.
|
||||||
|
worktrees: per linked worktree and orphaned `.claude/worktrees/agent-*`
|
||||||
|
dir: repo, path, branch, head, orphaned, missing, size_bytes,
|
||||||
|
last_activity (newest mtime inside or of its index/HEAD),
|
||||||
|
foreign_entries (not owned by the current uid, pruned at the first
|
||||||
|
per subtree), unreadable (directories that could not be listed).
|
||||||
|
preserved: per dirty worktree: repo, path, branch, paths (changed,
|
||||||
|
staged and untracked; None when status was not read), preserved,
|
||||||
|
reasons.
|
||||||
|
roots: per root: root, children over the threshold with path, kind
|
||||||
|
(worktree, git-repo, venv, node_modules, cache, core-dump, dir,
|
||||||
|
file), size_bytes, newest_mtime, unreadable; biggest first.
|
||||||
|
core_dumps: files named `core` or `core.<digits>` under the roots and
|
||||||
|
worktrees whose ELF e_type is ET_CORE: path, size_bytes,
|
||||||
|
apparent_bytes (st_size), mtime; biggest first.
|
||||||
|
docker: rows of type, total, active, size, reclaimable; None when not
|
||||||
|
asked for or unavailable.
|
||||||
|
balance: per filesystem of the roots and repository roots: filesystem
|
||||||
|
(mount point), total_bytes, free_bytes, attributed_bytes.
|
||||||
|
warnings: what could not be read.
|
||||||
|
"""
|
||||||
|
started = time.time()
|
||||||
|
warnings: list[str] = []
|
||||||
|
cores: list[str] = []
|
||||||
|
trees: list[dict] = []
|
||||||
|
kept: list[dict] = []
|
||||||
|
for repo in repo_roots:
|
||||||
|
try:
|
||||||
|
records = list_worktrees(repo, cores)
|
||||||
|
except (GitBaseError, OSError) as exc:
|
||||||
|
warnings.append(f"{repo}: worktrees not listed: {_describe(exc)}")
|
||||||
|
continue
|
||||||
|
trees += records
|
||||||
|
try:
|
||||||
|
kept += preserved(repo, records)
|
||||||
|
except (GitBaseError, OSError) as exc:
|
||||||
|
warnings.append(f"{repo}: preservation not checked: {_describe(exc)}")
|
||||||
|
scanned: list[dict] = []
|
||||||
|
for root in roots:
|
||||||
|
try:
|
||||||
|
scanned.append(scan_root(root, threshold_bytes, cores))
|
||||||
|
except OSError as exc:
|
||||||
|
warnings.append(f"{root}: not scanned: {_describe(exc)}")
|
||||||
|
dumps = core_dumps(cores)
|
||||||
|
usage = None
|
||||||
|
if docker:
|
||||||
|
usage, warning = docker_usage()
|
||||||
|
if warning:
|
||||||
|
warnings.append(warning)
|
||||||
|
items = [(t["path"], t["size_bytes"]) for t in trees if t["size_bytes"]]
|
||||||
|
items += [(c["path"], c["size_bytes"]) for s in scanned for c in s["children"]]
|
||||||
|
items += [(d["path"], d["size_bytes"]) for d in dumps]
|
||||||
|
return {
|
||||||
|
"generated_at": started,
|
||||||
|
"threshold_bytes": threshold_bytes,
|
||||||
|
"worktrees": trees,
|
||||||
|
"preserved": kept,
|
||||||
|
"roots": scanned,
|
||||||
|
"core_dumps": dumps,
|
||||||
|
"docker": usage,
|
||||||
|
"balance": balance([*roots, *repo_roots], items),
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from .core_dumps import is_core_dump
|
||||||
|
from .walk import walk
|
||||||
|
|
||||||
|
CACHE_TAG = "CACHEDIR.TAG"
|
||||||
|
NODE_MODULES = "node_modules"
|
||||||
|
|
||||||
|
|
||||||
|
def _has(path: str, name: str, check=os.path.isfile) -> bool:
|
||||||
|
return check(os.path.join(path, name))
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(path: str, st: os.stat_result) -> str:
|
||||||
|
name = os.path.basename(path)
|
||||||
|
if not stat.S_ISDIR(st.st_mode):
|
||||||
|
regular = stat.S_ISREG(st.st_mode)
|
||||||
|
return "core-dump" if regular and is_core_dump(path) else "file"
|
||||||
|
if name == NODE_MODULES:
|
||||||
|
return "node_modules"
|
||||||
|
if _has(path, ".git"):
|
||||||
|
return "worktree"
|
||||||
|
if _has(path, ".git", os.path.isdir) or (
|
||||||
|
_has(path, "HEAD") and _has(path, "objects", os.path.isdir)
|
||||||
|
):
|
||||||
|
return "git-repo"
|
||||||
|
if _has(path, "pyvenv.cfg"):
|
||||||
|
return "venv"
|
||||||
|
if _has(path, CACHE_TAG) or name.lower().endswith("cache"):
|
||||||
|
return "cache"
|
||||||
|
return "dir"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_root(root: str, threshold_bytes: int, cores: list[str]) -> dict:
|
||||||
|
"""
|
||||||
|
Measure the direct children of one root on the root's own filesystem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: the directory to inventory.
|
||||||
|
threshold_bytes: keep only children whose allocated size is above it.
|
||||||
|
cores: receives the core dump candidates found while measuring.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The root with its children over the threshold: path, kind, allocated
|
||||||
|
size, newest mtime inside and unlistable directories, biggest first.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: when the root itself cannot be listed.
|
||||||
|
"""
|
||||||
|
dev = os.stat(root).st_dev
|
||||||
|
children: list[dict] = []
|
||||||
|
with os.scandir(root) as entries:
|
||||||
|
listing = sorted(entries, key=lambda entry: entry.name)
|
||||||
|
for entry in listing:
|
||||||
|
try:
|
||||||
|
st = entry.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if st.st_dev != dev:
|
||||||
|
continue
|
||||||
|
stats = walk(entry.path, dev)
|
||||||
|
cores.extend(stats.cores)
|
||||||
|
if stats.size_bytes > threshold_bytes:
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"path": entry.path,
|
||||||
|
"kind": _kind(entry.path, st),
|
||||||
|
"size_bytes": stats.size_bytes,
|
||||||
|
"newest_mtime": stats.newest_mtime,
|
||||||
|
"unreadable": stats.unreadable,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
children.sort(key=lambda child: (-child["size_bytes"], child["path"]))
|
||||||
|
return {"root": root, "children": children}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
BLOCK = 512
|
||||||
|
CORE_NAME = re.compile(r"core(\.\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TreeStats:
|
||||||
|
size_bytes: int = 0
|
||||||
|
newest_mtime: float = 0.0
|
||||||
|
foreign_entries: int = 0
|
||||||
|
unreadable: int = 0
|
||||||
|
cores: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def is_foreign(path: str, st: os.stat_result) -> bool:
|
||||||
|
return st.st_uid != os.getuid()
|
||||||
|
|
||||||
|
|
||||||
|
def _account(
|
||||||
|
stats: TreeStats, path: str, st: os.stat_result, seen: set[tuple[int, int]]
|
||||||
|
) -> None:
|
||||||
|
if st.st_nlink > 1 and not stat.S_ISDIR(st.st_mode):
|
||||||
|
if (st.st_dev, st.st_ino) in seen:
|
||||||
|
return
|
||||||
|
seen.add((st.st_dev, st.st_ino))
|
||||||
|
stats.size_bytes += st.st_blocks * BLOCK
|
||||||
|
stats.newest_mtime = max(stats.newest_mtime, st.st_mtime)
|
||||||
|
if stat.S_ISREG(st.st_mode) and CORE_NAME.fullmatch(os.path.basename(path)):
|
||||||
|
stats.cores.append(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _children(path: str, stats: TreeStats) -> list[os.DirEntry]:
|
||||||
|
try:
|
||||||
|
with os.scandir(path) as entries:
|
||||||
|
return list(entries)
|
||||||
|
except OSError:
|
||||||
|
stats.unreadable += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def walk(path: str, dev: int | None = None) -> TreeStats:
|
||||||
|
"""
|
||||||
|
Measure a tree like `du -x`: allocated blocks, symlinks never followed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the tree root, measured itself as well.
|
||||||
|
dev: the filesystem (st_dev) to stay on; that of path when None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Allocated bytes (st_blocks * 512, each hard-linked inode once), the
|
||||||
|
newest mtime, entries not owned by the current uid (none counted below a
|
||||||
|
foreign one), directories that could not be listed, and the regular
|
||||||
|
files named `core` or `core.<digits>`.
|
||||||
|
"""
|
||||||
|
stats = TreeStats()
|
||||||
|
try:
|
||||||
|
top = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
stats.unreadable += 1
|
||||||
|
return stats
|
||||||
|
dev = top.st_dev if dev is None else dev
|
||||||
|
if top.st_dev != dev:
|
||||||
|
return stats
|
||||||
|
seen: set[tuple[int, int]] = set()
|
||||||
|
stack: list[tuple[str, os.stat_result, bool]] = [(path, top, False)]
|
||||||
|
while stack:
|
||||||
|
current, st, pruned = stack.pop()
|
||||||
|
_account(stats, current, st, seen)
|
||||||
|
foreign = not pruned and is_foreign(current, st)
|
||||||
|
stats.foreign_entries += foreign
|
||||||
|
if not stat.S_ISDIR(st.st_mode):
|
||||||
|
continue
|
||||||
|
for entry in _children(current, stats):
|
||||||
|
try:
|
||||||
|
child = entry.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if child.st_dev == dev:
|
||||||
|
stack.append((entry.path, child, pruned or foreign))
|
||||||
|
return stats
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import _worktrees
|
||||||
|
|
||||||
|
from .walk import TreeStats, walk
|
||||||
|
|
||||||
|
AGENT_DIR = os.path.join(".claude", "worktrees")
|
||||||
|
AGENT_PREFIX = "agent-"
|
||||||
|
METADATA_FILES = ("index", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_mtime(path: str) -> float:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
|
||||||
|
gitdir = fh.read().strip().removeprefix("gitdir: ")
|
||||||
|
except OSError:
|
||||||
|
return 0.0
|
||||||
|
meta = os.path.normpath(os.path.join(path, gitdir))
|
||||||
|
stamps = [0.0]
|
||||||
|
for name in METADATA_FILES:
|
||||||
|
try:
|
||||||
|
stamps.append(os.stat(os.path.join(meta, name)).st_mtime)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return max(stamps)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
branch: str | None,
|
||||||
|
head: str | None,
|
||||||
|
orphaned: bool,
|
||||||
|
cores: list[str],
|
||||||
|
) -> dict:
|
||||||
|
present = os.path.isdir(path)
|
||||||
|
stats = walk(path) if present else TreeStats()
|
||||||
|
cores.extend(stats.cores)
|
||||||
|
activity = max(stats.newest_mtime, _metadata_mtime(path)) if present else 0.0
|
||||||
|
return {
|
||||||
|
"repo": repo,
|
||||||
|
"path": path,
|
||||||
|
"branch": branch,
|
||||||
|
"head": head,
|
||||||
|
"orphaned": orphaned,
|
||||||
|
"missing": not present,
|
||||||
|
"size_bytes": stats.size_bytes if present else None,
|
||||||
|
"last_activity": activity or None,
|
||||||
|
"foreign_entries": stats.foreign_entries,
|
||||||
|
"unreadable": stats.unreadable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_dirs(repo: str) -> list[str]:
|
||||||
|
base = os.path.join(repo, AGENT_DIR)
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(base))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
os.path.join(base, name)
|
||||||
|
for name in names
|
||||||
|
if name.startswith(AGENT_PREFIX)
|
||||||
|
and os.path.isdir(os.path.join(base, name))
|
||||||
|
and not os.path.islink(os.path.join(base, name))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_worktrees(repo: str, cores: list[str]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Inventory the linked worktrees of one repository, orphaned agent dirs included.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: main worktree root of the repository.
|
||||||
|
cores: receives the core dump candidates found while measuring.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One record per linked worktree git lists and per `.claude/worktrees/agent-*`
|
||||||
|
directory it does not list (orphaned), sorted by path.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GitBaseError: when `git worktree list` fails.
|
||||||
|
"""
|
||||||
|
trees = [tree for tree in _worktrees(repo) if not tree.main]
|
||||||
|
listed = {os.path.realpath(tree.path) for tree in trees}
|
||||||
|
records = [
|
||||||
|
_record(repo, tree.path, tree.branch, tree.head, False, cores) for tree in trees
|
||||||
|
]
|
||||||
|
records += [
|
||||||
|
_record(repo, path, None, None, True, cores)
|
||||||
|
for path in _agent_dirs(repo)
|
||||||
|
if os.path.realpath(path) not in listed
|
||||||
|
]
|
||||||
|
return sorted(records, key=lambda record: record["path"])
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/__init__.py
|
# src/pkgmgr/actions/install/__init__.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
High-level entry point for repository installation.
|
High-level entry point for repository installation.
|
||||||
@@ -16,48 +14,49 @@ Responsibilities:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any
|
||||||
|
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
|
||||||
from pkgmgr.core.repository.verify import verify_repository
|
|
||||||
from pkgmgr.actions.repository.clone import clone_repos
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
from pkgmgr.actions.install.installers.makefile import (
|
||||||
|
MakefileInstaller,
|
||||||
|
)
|
||||||
|
from pkgmgr.actions.install.installers.nix import (
|
||||||
|
NixFlakeInstaller,
|
||||||
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages import (
|
from pkgmgr.actions.install.installers.os_packages import (
|
||||||
ArchPkgbuildInstaller,
|
ArchPkgbuildInstaller,
|
||||||
DebianControlInstaller,
|
DebianControlInstaller,
|
||||||
RpmSpecInstaller,
|
RpmSpecInstaller,
|
||||||
)
|
)
|
||||||
from pkgmgr.actions.install.installers.nix import (
|
|
||||||
NixFlakeInstaller,
|
|
||||||
)
|
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||||
from pkgmgr.actions.install.installers.makefile import (
|
|
||||||
MakefileInstaller,
|
|
||||||
)
|
|
||||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||||
|
from pkgmgr.actions.repository.clone import clone_repos
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
from pkgmgr.core.repository.verify import verify_repository
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = dict[str, Any]
|
||||||
|
|
||||||
INSTALLERS = [
|
INSTALLERS = [
|
||||||
|
MakefileInstaller(),
|
||||||
ArchPkgbuildInstaller(),
|
ArchPkgbuildInstaller(),
|
||||||
DebianControlInstaller(),
|
DebianControlInstaller(),
|
||||||
RpmSpecInstaller(),
|
RpmSpecInstaller(),
|
||||||
NixFlakeInstaller(),
|
NixFlakeInstaller(),
|
||||||
PythonInstaller(),
|
PythonInstaller(),
|
||||||
MakefileInstaller(),
|
|
||||||
]
|
]
|
||||||
|
"""Preference order: the pipeline runs the first supported hook and stops."""
|
||||||
|
|
||||||
|
|
||||||
def _ensure_repo_dir(
|
def _ensure_repo_dir(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Compute and, if necessary, clone the repository directory.
|
Compute and, if necessary, clone the repository directory.
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ def _create_context(
|
|||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
@@ -155,10 +154,10 @@ def _create_context(
|
|||||||
|
|
||||||
|
|
||||||
def install_repos(
|
def install_repos(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
@@ -179,7 +178,7 @@ def install_repos(
|
|||||||
overall command never exits non-zero because of per-repository failures.
|
overall command never exits non-zero because of per-repository failures.
|
||||||
"""
|
"""
|
||||||
pipeline = InstallationPipeline(INSTALLERS)
|
pipeline = InstallationPipeline(INSTALLERS)
|
||||||
failures: List[Tuple[str, str]] = []
|
failures: list[tuple[str, str]] = []
|
||||||
|
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
identifier = get_repo_identifier(repo, all_repos)
|
identifier = get_repo_identifier(repo, all_repos)
|
||||||
@@ -232,7 +231,7 @@ def install_repos(
|
|||||||
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
|
||||||
failures.append((identifier, f"unexpected error: {exc}"))
|
failures.append((identifier, f"unexpected error: {exc}"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Capability detection for pkgmgr.
|
Capability detection for pkgmgr.
|
||||||
|
|
||||||
@@ -35,7 +32,8 @@ from __future__ import annotations
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Iterable, TYPE_CHECKING, Optional
|
from collections.abc import Iterable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
@@ -46,12 +44,12 @@ if TYPE_CHECKING:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _read_text_if_exists(path: str) -> Optional[str]:
|
def _read_text_if_exists(path: str) -> str | None:
|
||||||
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
@@ -75,12 +73,12 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _first_spec_file(repo_dir: str) -> Optional[str]:
|
def _first_spec_file(repo_dir: str) -> str | None:
|
||||||
"""Return the first *.spec file in repo_dir, if any."""
|
"""Return the first *.spec file in repo_dir, if any."""
|
||||||
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
||||||
if not matches:
|
if not matches:
|
||||||
return None
|
return None
|
||||||
return sorted(matches)[0]
|
return min(matches)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -100,7 +98,7 @@ class CapabilityMatcher(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if this capability is actually provided by the given layer
|
Return True if this capability is actually provided by the given layer
|
||||||
for this repository.
|
for this repository.
|
||||||
@@ -133,7 +131,7 @@ class PythonRuntimeCapability(CapabilityMatcher):
|
|||||||
# OS packages may wrap Python builds, but must explicitly prove it
|
# OS packages may wrap Python builds, but must explicitly prove it
|
||||||
return layer in {"python", "nix", "os-packages"}
|
return layer in {"python", "nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "python":
|
if layer == "python":
|
||||||
@@ -208,7 +206,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
|||||||
def applies_to_layer(self, layer: str) -> bool:
|
def applies_to_layer(self, layer: str) -> bool:
|
||||||
return layer in {"makefile", "python", "nix", "os-packages"}
|
return layer in {"makefile", "python", "nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "makefile":
|
if layer == "makefile":
|
||||||
@@ -216,7 +214,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
|||||||
if not os.path.exists(makefile):
|
if not os.path.exists(makefile):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with open(makefile, "r", encoding="utf-8") as f:
|
with open(makefile, encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if line.strip().startswith("install:"):
|
if line.strip().startswith("install:"):
|
||||||
return True
|
return True
|
||||||
@@ -274,7 +272,7 @@ class NixFlakeCapability(CapabilityMatcher):
|
|||||||
# Only Nix itself and OS packages that explicitly wrap Nix
|
# Only Nix itself and OS packages that explicitly wrap Nix
|
||||||
return layer in {"nix", "os-packages"}
|
return layer in {"nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "nix":
|
if layer == "nix":
|
||||||
@@ -328,7 +326,7 @@ LAYER_ORDER: list[str] = [
|
|||||||
|
|
||||||
|
|
||||||
def detect_capabilities(
|
def detect_capabilities(
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
layers: Iterable[str],
|
layers: Iterable[str],
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -359,8 +357,8 @@ def detect_capabilities(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_effective_capabilities(
|
def resolve_effective_capabilities(
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
layers: Optional[Iterable[str]] = None,
|
layers: Iterable[str] | None = None,
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
||||||
@@ -381,10 +379,7 @@ def resolve_effective_capabilities(
|
|||||||
This means *any* higher layer can overshadow a lower layer, not just
|
This means *any* higher layer can overshadow a lower layer, not just
|
||||||
a specific one like Nix. The resolver is completely generic.
|
a specific one like Nix. The resolver is completely generic.
|
||||||
"""
|
"""
|
||||||
if layers is None:
|
layers_list = list(LAYER_ORDER) if layers is None else list(layers)
|
||||||
layers_list = list(LAYER_ORDER)
|
|
||||||
else:
|
|
||||||
layers_list = list(layers)
|
|
||||||
|
|
||||||
raw_caps = detect_capabilities(ctx, layers_list)
|
raw_caps = detect_capabilities(ctx, layers_list)
|
||||||
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
|
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/context.py
|
# src/pkgmgr/actions/install/context.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Shared context object for repository installation steps.
|
Shared context object for repository installation steps.
|
||||||
@@ -10,19 +8,19 @@ they do not depend on global state or long parameter lists.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RepoContext:
|
class RepoContext:
|
||||||
"""Container for all repository-related data used during installation."""
|
"""Container for all repository-related data used during installation."""
|
||||||
|
|
||||||
repo: Dict[str, Any]
|
repo: dict[str, Any]
|
||||||
identifier: str
|
identifier: str
|
||||||
repo_dir: str
|
repo_dir: str
|
||||||
repositories_base_dir: str
|
repositories_base_dir: str
|
||||||
bin_dir: str
|
bin_dir: str
|
||||||
all_repos: List[Dict[str, Any]]
|
all_repos: list[dict[str, Any]]
|
||||||
|
|
||||||
no_verification: bool
|
no_verification: bool
|
||||||
preview: bool
|
preview: bool
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer package for pkgmgr.
|
Installer package for pkgmgr.
|
||||||
|
|
||||||
@@ -9,15 +6,17 @@ pkgmgr.actions.install.installers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
|
||||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||||
|
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
||||||
|
|
||||||
# OS-specific installers
|
# OS-specific installers
|
||||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||||
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
||||||
) # noqa: F401
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
||||||
DebianControlInstaller as DebianControlInstaller,
|
DebianControlInstaller as DebianControlInstaller,
|
||||||
) # noqa: F401
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
|
||||||
|
RpmSpecInstaller, # noqa: F401
|
||||||
|
)
|
||||||
|
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Base interface for all installer components in the pkgmgr installation pipeline.
|
Base interface for all installer components in the pkgmgr installation pipeline.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Set, Optional
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
|
||||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||||
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
|
||||||
|
|
||||||
class BaseInstaller(ABC):
|
class BaseInstaller(ABC):
|
||||||
@@ -24,9 +21,9 @@ class BaseInstaller(ABC):
|
|||||||
# Examples: "nix", "python", "makefile".
|
# Examples: "nix", "python", "makefile".
|
||||||
# This is used by capability matchers to decide which patterns to
|
# This is used by capability matchers to decide which patterns to
|
||||||
# search for in the repository.
|
# search for in the repository.
|
||||||
layer: Optional[str] = None
|
layer: str | None = None
|
||||||
|
|
||||||
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
def discover_capabilities(self, ctx: RepoContext) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Determine which logical capabilities this installer will provide
|
Determine which logical capabilities this installer will provide
|
||||||
for this specific repository instance.
|
for this specific repository instance.
|
||||||
@@ -36,7 +33,7 @@ class BaseInstaller(ABC):
|
|||||||
Makefile, etc.) and decide, via string matching, whether a given
|
Makefile, etc.) and decide, via string matching, whether a given
|
||||||
capability is actually provided by this layer.
|
capability is actually provided by this layer.
|
||||||
"""
|
"""
|
||||||
caps: Set[str] = set()
|
caps: set[str] = set()
|
||||||
if not self.layer:
|
if not self.layer:
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,12 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
|
|
||||||
def _has_install_target(self, makefile_path: str) -> bool:
|
def _has_install_target(self, makefile_path: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
with open(makefile_path, encoding="utf-8", errors="ignore") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE))
|
||||||
return True
|
|
||||||
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .profile import NixProfileInspector
|
from .profile import NixProfileInspector
|
||||||
from .retry import GitHubRateLimitRetry
|
from .retry import GitHubRateLimitRetry
|
||||||
@@ -33,7 +33,7 @@ class NixConflictResolver:
|
|||||||
|
|
||||||
def resolve(
|
def resolve(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
install_cmd: str,
|
install_cmd: str,
|
||||||
stdout: str,
|
stdout: str,
|
||||||
stderr: str,
|
stderr: str,
|
||||||
@@ -49,7 +49,7 @@ class NixConflictResolver:
|
|||||||
store_prefixes = self._parser.existing_store_prefixes(combined)
|
store_prefixes = self._parser.existing_store_prefixes(combined)
|
||||||
|
|
||||||
# 2) Resolve them to concrete remove tokens
|
# 2) Resolve them to concrete remove tokens
|
||||||
tokens: List[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
tokens: list[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
||||||
ctx,
|
ctx,
|
||||||
self._runner,
|
self._runner,
|
||||||
store_prefixes,
|
store_prefixes,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
|
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
|
||||||
self._indices_supported: bool | None = None
|
self._indices_supported: bool | None = None
|
||||||
|
|
||||||
def supports(self, ctx: "RepoContext") -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(
|
print(
|
||||||
@@ -42,13 +42,13 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
||||||
|
|
||||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
def _profile_outputs(self, ctx: RepoContext) -> list[tuple[str, bool]]:
|
||||||
# (output_name, allow_failure)
|
# (output_name, allow_failure)
|
||||||
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
||||||
return [("pkgmgr", False), ("default", True)]
|
return [("pkgmgr", False), ("default", True)]
|
||||||
return [("default", False)]
|
return [("default", False)]
|
||||||
|
|
||||||
def run(self, ctx: "RepoContext") -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
if not self.supports(ctx):
|
if not self.supports(ctx):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
else:
|
else:
|
||||||
self._install_only(ctx, output, allow_failure)
|
self._install_only(ctx, output, allow_failure)
|
||||||
|
|
||||||
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
def _installable(self, ctx: RepoContext, output: str) -> str:
|
||||||
return f"{ctx.repo_dir}#{output}"
|
return f"{ctx.repo_dir}#{output}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
@@ -76,7 +76,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _install_only(
|
def _install_only(
|
||||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _force_upgrade_output(
|
def _force_upgrade_output(
|
||||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
# Prefer token path if indices unsupported (new nix)
|
# Prefer token path if indices unsupported (new nix)
|
||||||
if self._indices_supported is False:
|
if self._indices_supported is False:
|
||||||
@@ -215,7 +215,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
s = (stderr or "").lower()
|
s = (stderr or "").lower()
|
||||||
return "no longer supports indices" in s or "does not support indices" in s
|
return "no longer supports indices" in s or "does not support indices" in s
|
||||||
|
|
||||||
def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
|
def _upgrade_index(self, ctx: RepoContext, idx: int) -> bool:
|
||||||
cmd = f"nix profile upgrade --refresh {idx}"
|
cmd = f"nix profile upgrade --refresh {idx}"
|
||||||
res = self._runner.run(ctx, cmd, allow_failure=True)
|
res = self._runner.run(ctx, cmd, allow_failure=True)
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return res.returncode == 0
|
return res.returncode == 0
|
||||||
|
|
||||||
def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
|
def _remove_index(self, ctx: RepoContext, idx: int) -> None:
|
||||||
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
||||||
|
|
||||||
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
|
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
|
||||||
@@ -237,7 +237,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
if self._indices_supported is None:
|
if self._indices_supported is None:
|
||||||
self._indices_supported = True
|
self._indices_supported = True
|
||||||
|
|
||||||
def _remove_tokens_for_output(self, ctx: "RepoContext", output: str) -> None:
|
def _remove_tokens_for_output(self, ctx: RepoContext, output: str) -> None:
|
||||||
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .inspector import NixProfileInspector
|
from .inspector import NixProfileInspector
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
__all__ = ["NixProfileInspector", "NixProfileEntry"]
|
__all__ = ["NixProfileEntry", "NixProfileInspector"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, List, TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from .matcher import (
|
from .matcher import (
|
||||||
entry_matches_output,
|
entry_matches_output,
|
||||||
@@ -29,7 +29,7 @@ class NixProfileInspector:
|
|||||||
- find_remove_tokens_for_store_prefixes()
|
- find_remove_tokens_for_store_prefixes()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
|
def list_json(self, ctx: RepoContext, runner: CommandRunner) -> dict[str, Any]:
|
||||||
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
|
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
|
||||||
raw = extract_stdout_text(res)
|
raw = extract_stdout_text(res)
|
||||||
return parse_profile_list_json(raw)
|
return parse_profile_list_json(raw)
|
||||||
@@ -40,14 +40,14 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_installed_indices_for_output(
|
def find_installed_indices_for_output(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
output: str,
|
output: str,
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.index is None:
|
if e.index is None:
|
||||||
continue
|
continue
|
||||||
@@ -58,10 +58,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_indices_by_store_path(
|
def find_indices_by_store_path(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
store_path: str,
|
store_path: str,
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
needle = (store_path or "").strip()
|
needle = (store_path or "").strip()
|
||||||
if not needle:
|
if not needle:
|
||||||
return []
|
return []
|
||||||
@@ -69,7 +69,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.index is None:
|
if e.index is None:
|
||||||
continue
|
continue
|
||||||
@@ -84,10 +84,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_remove_tokens_for_output(
|
def find_remove_tokens_for_output(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
output: str,
|
output: str,
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Returns profile remove tokens to remove entries matching a given output.
|
Returns profile remove tokens to remove entries matching a given output.
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
tokens: List[str] = [
|
tokens: list[str] = [
|
||||||
out
|
out
|
||||||
] # critical: matches nix's own suggestion for conflicts
|
] # critical: matches nix's own suggestion for conflicts
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
# stable unique preserving order
|
# stable unique preserving order
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t and t not in seen:
|
if t and t not in seen:
|
||||||
uniq.append(t)
|
uniq.append(t)
|
||||||
@@ -128,10 +128,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_remove_tokens_for_store_prefixes(
|
def find_remove_tokens_for_store_prefixes(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
prefixes: List[str],
|
prefixes: list[str],
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Returns remove tokens for entries whose store path matches any prefix.
|
Returns remove tokens for entries whose store path matches any prefix.
|
||||||
"""
|
"""
|
||||||
@@ -143,7 +143,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
tokens: List[str] = []
|
tokens: list[str] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if not e.store_paths:
|
if not e.store_paths:
|
||||||
continue
|
continue
|
||||||
@@ -156,7 +156,7 @@ class NixProfileInspector:
|
|||||||
tokens.append(n)
|
tokens.append(n)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t and t not in seen:
|
if t and t not in seen:
|
||||||
uniq.append(t)
|
uniq.append(t)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
|
|
||||||
@@ -51,9 +49,9 @@ def entry_matches_store_path(entry: NixProfileEntry, store_path: str) -> bool:
|
|||||||
return any((p or "") == needle for p in entry.store_paths)
|
return any((p or "") == needle for p in entry.store_paths)
|
||||||
|
|
||||||
|
|
||||||
def stable_unique_ints(values: List[int]) -> List[int]:
|
def stable_unique_ints(values: list[int]) -> list[int]:
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[int] = []
|
uniq: list[int] = []
|
||||||
for v in values:
|
for v in values:
|
||||||
if v in seen:
|
if v in seen:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -11,7 +10,7 @@ class NixProfileEntry:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
index: Optional[int]
|
index: int | None
|
||||||
name: str
|
name: str
|
||||||
attr_path: str
|
attr_path: str
|
||||||
store_paths: List[str]
|
store_paths: list[str]
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
|
|
||||||
def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
|
||||||
"""
|
"""
|
||||||
Nix JSON schema varies:
|
Nix JSON schema varies:
|
||||||
- elements keys might be "0", "1", ...
|
- elements keys might be "0", "1", ...
|
||||||
@@ -20,7 +21,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if k.isdigit():
|
if k.isdigit():
|
||||||
try:
|
try:
|
||||||
return int(k)
|
return int(k)
|
||||||
except Exception:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 2) Explicit index fields (schema-dependent)
|
# 2) Explicit index fields (schema-dependent)
|
||||||
@@ -31,7 +32,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if isinstance(v, str) and v.strip().isdigit():
|
if isinstance(v, str) and v.strip().isdigit():
|
||||||
try:
|
try:
|
||||||
return int(v.strip())
|
return int(v.strip())
|
||||||
except Exception:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
|
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
|
||||||
@@ -39,13 +40,13 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if m:
|
if m:
|
||||||
try:
|
try:
|
||||||
return int(m.group(1))
|
return int(m.group(1))
|
||||||
except Exception:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
def iter_store_paths(entry: dict[str, Any]) -> Iterable[str]:
|
||||||
"""
|
"""
|
||||||
Yield all possible store paths from a nix profile JSON entry.
|
Yield all possible store paths from a nix profile JSON entry.
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
|||||||
|
|
||||||
outs = entry.get("outputs")
|
outs = entry.get("outputs")
|
||||||
if isinstance(outs, dict):
|
if isinstance(outs, dict):
|
||||||
for _, ov in outs.items():
|
for ov in outs.values():
|
||||||
if isinstance(ov, dict):
|
if isinstance(ov, dict):
|
||||||
p = ov.get("storePath")
|
p = ov.get("storePath")
|
||||||
if isinstance(p, str):
|
if isinstance(p, str):
|
||||||
@@ -87,7 +88,7 @@ def normalize_store_path(store_path: str) -> str:
|
|||||||
return (store_path or "").strip()
|
return (store_path or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
def normalize_elements(data: dict[str, Any]) -> list[NixProfileEntry]:
|
||||||
"""
|
"""
|
||||||
Converts nix profile list JSON into a list of normalized entries.
|
Converts nix profile list JSON into a list of normalized entries.
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
|||||||
if not isinstance(elements, dict):
|
if not isinstance(elements, dict):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
normalized: List[NixProfileEntry] = []
|
normalized: list[NixProfileEntry] = []
|
||||||
|
|
||||||
for k, entry in elements.items():
|
for k, entry in elements.items():
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@@ -109,7 +110,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
|||||||
name = str(entry.get("name", "") or "")
|
name = str(entry.get("name", "") or "")
|
||||||
attr = str(entry.get("attrPath", "") or "")
|
attr = str(entry.get("attrPath", "") or "")
|
||||||
|
|
||||||
store_paths: List[str] = []
|
store_paths: list[str] = []
|
||||||
for p in iter_store_paths(entry):
|
for p in iter_store_paths(entry):
|
||||||
sp = normalize_store_path(p)
|
sp = normalize_store_path(p)
|
||||||
if sp:
|
if sp:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def parse_profile_list_json(raw: str) -> Dict[str, Any]:
|
def parse_profile_list_json(raw: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Parse JSON output from `nix profile list --json`.
|
Parse JSON output from `nix profile list --json`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .runner import CommandRunner
|
from .runner import CommandRunner
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ class NixProfileListReader:
|
|||||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||||
return m.group(1) if m else raw
|
return m.group(1) if m else raw
|
||||||
|
|
||||||
def entries(self, ctx: "RepoContext") -> List[Tuple[int, str]]:
|
def entries(self, ctx: RepoContext) -> list[tuple[int, str]]:
|
||||||
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
|
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
entries: List[Tuple[int, str]] = []
|
entries: list[tuple[int, str]] = []
|
||||||
pat = re.compile(
|
pat = re.compile(
|
||||||
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
|
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
@@ -35,12 +35,12 @@ class NixProfileListReader:
|
|||||||
sp = m.group(2)
|
sp = m.group(2)
|
||||||
try:
|
try:
|
||||||
idx = int(idx_s)
|
idx = int(idx_s)
|
||||||
except Exception:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
entries.append((idx, self._store_prefix(sp)))
|
entries.append((idx, self._store_prefix(sp)))
|
||||||
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[Tuple[int, str]] = []
|
uniq: list[tuple[int, str]] = []
|
||||||
for idx, sp in entries:
|
for idx, sp in entries:
|
||||||
if idx not in seen:
|
if idx not in seen:
|
||||||
seen.add(idx)
|
seen.add(idx)
|
||||||
@@ -49,20 +49,20 @@ class NixProfileListReader:
|
|||||||
return uniq
|
return uniq
|
||||||
|
|
||||||
def indices_matching_store_prefixes(
|
def indices_matching_store_prefixes(
|
||||||
self, ctx: "RepoContext", prefixes: List[str]
|
self, ctx: RepoContext, prefixes: list[str]
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
||||||
prefixes = [p for p in prefixes if p]
|
prefixes = [p for p in prefixes if p]
|
||||||
if not prefixes:
|
if not prefixes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for idx, sp in self.entries(ctx):
|
for idx, sp in self.entries(ctx):
|
||||||
if any(sp == p for p in prefixes):
|
if any(sp == p for p in prefixes):
|
||||||
hits.append(idx)
|
hits.append(idx)
|
||||||
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[int] = []
|
uniq: list[int] = []
|
||||||
for i in hits:
|
for i in hits:
|
||||||
if i not in seen:
|
if i not in seen:
|
||||||
seen.add(i)
|
seen.add(i)
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .types import RunResult
|
from .types import RunResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
|
||||||
from .runner import CommandRunner
|
from .runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -31,8 +33,8 @@ class GitHubRateLimitRetry:
|
|||||||
|
|
||||||
def run_with_retry(
|
def run_with_retry(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
install_cmd: str,
|
install_cmd: str,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
quiet = bool(getattr(ctx, "quiet", False))
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .types import RunResult
|
from .types import RunResult
|
||||||
@@ -16,7 +15,7 @@ class CommandRunner:
|
|||||||
Supports preview mode and compact failure output logging.
|
Supports preview mode and compact failure output logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def run(self, ctx: "RepoContext", cmd: str, allow_failure: bool) -> RunResult:
|
def run(self, ctx: RepoContext, cmd: str, allow_failure: bool) -> RunResult:
|
||||||
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
||||||
preview = bool(getattr(ctx, "preview", False))
|
preview = bool(getattr(ctx, "preview", False))
|
||||||
quiet = bool(getattr(ctx, "quiet", False))
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
@@ -32,8 +31,7 @@ class CommandRunner:
|
|||||||
shell=True,
|
shell=True,
|
||||||
cwd=repo_dir,
|
cwd=repo_dir,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
capture_output=True,
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
class NixConflictTextParser:
|
class NixConflictTextParser:
|
||||||
@@ -11,13 +10,13 @@ class NixConflictTextParser:
|
|||||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||||
return m.group(1) if m else raw
|
return m.group(1) if m else raw
|
||||||
|
|
||||||
def remove_tokens(self, text: str) -> List[str]:
|
def remove_tokens(self, text: str) -> list[str]:
|
||||||
pat = re.compile(
|
pat = re.compile(
|
||||||
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
|
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
tokens: List[str] = []
|
tokens: list[str] = []
|
||||||
for m in pat.finditer(text or ""):
|
for m in pat.finditer(text or ""):
|
||||||
t = (m.group(1) or "").strip()
|
t = (m.group(1) or "").strip()
|
||||||
if (t.startswith("'") and t.endswith("'")) or (
|
if (t.startswith("'") and t.endswith("'")) or (
|
||||||
@@ -28,7 +27,7 @@ class NixConflictTextParser:
|
|||||||
tokens.append(t)
|
tokens.append(t)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t not in seen:
|
if t not in seen:
|
||||||
seen.add(t)
|
seen.add(t)
|
||||||
@@ -36,9 +35,9 @@ class NixConflictTextParser:
|
|||||||
|
|
||||||
return uniq
|
return uniq
|
||||||
|
|
||||||
def existing_store_prefixes(self, text: str) -> List[str]:
|
def existing_store_prefixes(self, text: str) -> list[str]:
|
||||||
lines = (text or "").splitlines()
|
lines = (text or "").splitlines()
|
||||||
prefixes: List[str] = []
|
prefixes: list[str] = []
|
||||||
|
|
||||||
in_existing = False
|
in_existing = False
|
||||||
in_new = False
|
in_new = False
|
||||||
@@ -69,7 +68,7 @@ class NixConflictTextParser:
|
|||||||
norm = [self._store_prefix(p) for p in prefixes if p]
|
norm = [self._store_prefix(p) for p in prefixes if p]
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for p in norm:
|
for p in norm:
|
||||||
if p and p not in seen:
|
if p and p not in seen:
|
||||||
seen.add(p)
|
seen.add(p)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ArchPkgbuildInstaller(BaseInstaller):
|
|||||||
try:
|
try:
|
||||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except (AttributeError, OSError):
|
||||||
# On non-POSIX platforms just ignore this check.
|
# On non-POSIX platforms just ignore this check.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer for Debian/Ubuntu packages defined via debian/control.
|
Installer for Debian/Ubuntu packages defined via debian/control.
|
||||||
|
|
||||||
@@ -13,11 +10,11 @@ This installer:
|
|||||||
It is intended for Debian-based systems where dpkg-buildpackage and
|
It is intended for Debian-based systems where dpkg-buildpackage and
|
||||||
apt/dpkg tooling are available.
|
apt/dpkg tooling are available.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -56,7 +53,7 @@ class DebianControlInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return os.path.exists(self._control_path(ctx))
|
return os.path.exists(self._control_path(ctx))
|
||||||
|
|
||||||
def _find_built_debs(self, repo_dir: str) -> List[str]:
|
def _find_built_debs(self, repo_dir: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Find .deb files built by dpkg-buildpackage.
|
Find .deb files built by dpkg-buildpackage.
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ class DebianControlInstaller(BaseInstaller):
|
|||||||
pattern = os.path.join(parent, "*.deb")
|
pattern = os.path.join(parent, "*.deb")
|
||||||
return sorted(glob.glob(pattern))
|
return sorted(glob.glob(pattern))
|
||||||
|
|
||||||
def _privileged_prefix(self) -> Optional[str]:
|
def _privileged_prefix(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Determine how to run privileged commands:
|
Determine how to run privileged commands:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer for RPM-based packages defined in *.spec files.
|
Installer for RPM-based packages defined in *.spec files.
|
||||||
|
|
||||||
@@ -14,12 +11,12 @@ This installer:
|
|||||||
|
|
||||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -53,7 +50,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return has_dnf or has_yum or has_yum_builddep
|
return has_dnf or has_yum or has_yum_builddep
|
||||||
|
|
||||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
def _spec_path(self, ctx: RepoContext) -> str | None:
|
||||||
"""Return the first *.spec file in the repository root, if any."""
|
"""Return the first *.spec file in the repository root, if any."""
|
||||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||||
matches = sorted(glob.glob(pattern))
|
matches = sorted(glob.glob(pattern))
|
||||||
@@ -92,7 +89,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
||||||
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
||||||
|
|
||||||
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
|
def _parse_name_version(self, spec_path: str) -> tuple[str, str] | None:
|
||||||
"""
|
"""
|
||||||
Parse Name and Version from the given .spec file.
|
Parse Name and Version from the given .spec file.
|
||||||
|
|
||||||
@@ -101,7 +98,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
name = None
|
name = None
|
||||||
version = None
|
version = None
|
||||||
|
|
||||||
with open(spec_path, "r", encoding="utf-8") as f:
|
with open(spec_path, encoding="utf-8") as f:
|
||||||
for raw_line in f:
|
for raw_line in f:
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
# Ignore comments
|
# Ignore comments
|
||||||
@@ -183,7 +180,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return self._spec_path(ctx) is not None
|
return self._spec_path(ctx) is not None
|
||||||
|
|
||||||
def _find_built_rpms(self) -> List[str]:
|
def _find_built_rpms(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Find RPMs built by rpmbuild.
|
Find RPMs built by rpmbuild.
|
||||||
|
|
||||||
@@ -202,9 +199,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
if shutil.which("dnf") is not None:
|
if shutil.which("dnf") is not None:
|
||||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||||
elif shutil.which("yum-builddep") is not None:
|
elif shutil.which("yum-builddep") is not None or shutil.which("yum") is not None:
|
||||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
|
||||||
elif shutil.which("yum") is not None:
|
|
||||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
@@ -215,7 +210,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
|
|
||||||
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
|
def _install_built_rpms(self, ctx: RepoContext, rpms: list[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Install or upgrade the built RPMs.
|
Install or upgrade the built RPMs.
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
CLI layer model for the pkgmgr installation pipeline.
|
CLI layer model for the pkgmgr installation pipeline.
|
||||||
|
|
||||||
@@ -19,7 +16,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class CliLayer(str, Enum):
|
class CliLayer(str, Enum):
|
||||||
@@ -38,7 +34,7 @@ CLI_LAYERS: list[CliLayer] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def layer_priority(layer: Optional[CliLayer]) -> int:
|
def layer_priority(layer: CliLayer | None) -> int:
|
||||||
"""
|
"""
|
||||||
Return a numeric priority index for a given layer.
|
Return a numeric priority index for a given layer.
|
||||||
|
|
||||||
@@ -70,13 +66,11 @@ def classify_command_layer(command: str, repo_dir: str) -> CliLayer:
|
|||||||
home = os.path.expanduser("~")
|
home = os.path.expanduser("~")
|
||||||
|
|
||||||
# OS package managers
|
# OS package managers
|
||||||
if command_abs.startswith("/usr/") or command_abs.startswith("/bin/"):
|
if command_abs.startswith(("/usr/", "/bin/")):
|
||||||
return CliLayer.OS_PACKAGES
|
return CliLayer.OS_PACKAGES
|
||||||
|
|
||||||
# Nix store / profile
|
# Nix store / profile
|
||||||
if command_abs.startswith("/nix/store/") or command_abs.startswith(
|
if command_abs.startswith(("/nix/store/", os.path.join(home, ".nix-profile"))):
|
||||||
os.path.join(home, ".nix-profile")
|
|
||||||
):
|
|
||||||
return CliLayer.NIX
|
return CliLayer.NIX
|
||||||
|
|
||||||
# User-local bin
|
# User-local bin
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/pipeline.py
|
# src/pkgmgr/actions/install/pipeline.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installation pipeline orchestration for repositories.
|
Installation pipeline orchestration for repositories.
|
||||||
@@ -8,8 +6,8 @@ Installation pipeline orchestration for repositories.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Sequence, Set
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -24,8 +22,8 @@ from pkgmgr.core.command.resolve import resolve_command_for_repo
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandState:
|
class CommandState:
|
||||||
command: Optional[str]
|
command: str | None
|
||||||
layer: Optional[CliLayer]
|
layer: CliLayer | None
|
||||||
|
|
||||||
|
|
||||||
class CommandResolver:
|
class CommandResolver:
|
||||||
@@ -83,13 +81,15 @@ class InstallationPipeline:
|
|||||||
else:
|
else:
|
||||||
repo.pop("command", None)
|
repo.pop("command", None)
|
||||||
|
|
||||||
provided_capabilities: Set[str] = set()
|
attempted: list[str] = []
|
||||||
|
|
||||||
for installer in self._installers:
|
for installer in self._installers:
|
||||||
layer_name = getattr(installer, "layer", None)
|
layer_name = getattr(installer, "layer", None)
|
||||||
|
|
||||||
if layer_name is None:
|
if layer_name is None:
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
if self._run_installer(installer, ctx, identifier, repo_dir, quiet):
|
||||||
|
return
|
||||||
|
attempted.append(installer.__class__.__name__)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -122,15 +122,6 @@ class InstallationPipeline:
|
|||||||
if not installer.supports(ctx):
|
if not installer.supports(ctx):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
caps = installer.discover_capabilities(ctx)
|
|
||||||
if caps and caps.issubset(provided_capabilities):
|
|
||||||
if not quiet:
|
|
||||||
print(
|
|
||||||
f"Skipping installer {installer.__class__.__name__} "
|
|
||||||
f"for {identifier} – capabilities {caps} already provided."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
if (
|
if (
|
||||||
ctx.force_update
|
ctx.force_update
|
||||||
@@ -144,13 +135,17 @@ class InstallationPipeline:
|
|||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||||
f"for {identifier} in '{repo_dir}' "
|
f"for {identifier} in '{repo_dir}'..."
|
||||||
f"(new capabilities: {caps or set()})..."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
if not self._run_installer(installer, ctx, identifier, repo_dir, quiet):
|
||||||
|
attempted.append(installer.__class__.__name__)
|
||||||
provided_capabilities.update(caps)
|
if not quiet:
|
||||||
|
print(
|
||||||
|
f"[pkgmgr] {installer.__class__.__name__} installed nothing "
|
||||||
|
f"for {identifier}; falling back to the next hook."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
new_state = resolver.resolve()
|
new_state = resolver.resolve()
|
||||||
if new_state.command:
|
if new_state.command:
|
||||||
@@ -167,6 +162,13 @@ class InstallationPipeline:
|
|||||||
repo.pop("command", None)
|
repo.pop("command", None)
|
||||||
|
|
||||||
state = new_state
|
state = new_state
|
||||||
|
return
|
||||||
|
|
||||||
|
if attempted:
|
||||||
|
raise SystemExit(
|
||||||
|
f"every installation hook failed for {identifier}: "
|
||||||
|
f"{', '.join(attempted)}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_installer(
|
def _run_installer(
|
||||||
@@ -175,9 +177,11 @@ class InstallationPipeline:
|
|||||||
identifier: str,
|
identifier: str,
|
||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
|
"""Run one hook. Returns whether it installed anything."""
|
||||||
try:
|
try:
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
|
return True
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||||
print(
|
print(
|
||||||
@@ -195,4 +199,4 @@ class InstallationPipeline:
|
|||||||
f" pkgmgr install {identifier} "
|
f" pkgmgr install {identifier} "
|
||||||
"--clone-mode shallow --no-verification"
|
"--clone-mode shallow --no-verification"
|
||||||
)
|
)
|
||||||
raise
|
return False
|
||||||
@@ -9,19 +9,20 @@ Public API:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from .types import Repository, MirrorMap
|
|
||||||
from .list_cmd import list_mirrors
|
|
||||||
from .diff_cmd import diff_mirrors
|
from .diff_cmd import diff_mirrors
|
||||||
|
from .list_cmd import list_mirrors
|
||||||
from .merge_cmd import merge_mirrors
|
from .merge_cmd import merge_mirrors
|
||||||
from .setup_cmd import setup_mirrors
|
from .setup_cmd import setup_mirrors
|
||||||
|
from .types import MirrorMap, Repository
|
||||||
from .visibility_cmd import set_mirror_visibility
|
from .visibility_cmd import set_mirror_visibility
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Repository",
|
|
||||||
"MirrorMap",
|
"MirrorMap",
|
||||||
"list_mirrors",
|
"Repository",
|
||||||
"diff_mirrors",
|
"diff_mirrors",
|
||||||
|
"list_mirrors",
|
||||||
"merge_mirrors",
|
"merge_mirrors",
|
||||||
"setup_mirrors",
|
|
||||||
"set_mirror_visibility",
|
"set_mirror_visibility",
|
||||||
|
"setup_mirrors",
|
||||||
]
|
]
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
|
||||||
@@ -12,7 +10,7 @@ from .types import MirrorMap, RepoMirrorContext, Repository
|
|||||||
def build_context(
|
def build_context(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
) -> RepoMirrorContext:
|
) -> RepoMirrorContext:
|
||||||
"""
|
"""
|
||||||
Build a RepoMirrorContext for a single repository.
|
Build a RepoMirrorContext for a single repository.
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .printing import print_header
|
from .printing import print_header
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
|
|
||||||
def diff_mirrors(
|
def diff_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Show differences between config mirrors and MIRRORS file.
|
Show differences between config mirrors and MIRRORS file.
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Set
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitAddRemoteError,
|
GitAddRemoteError,
|
||||||
GitAddRemotePushUrlError,
|
GitAddRemotePushUrlError,
|
||||||
@@ -12,6 +10,7 @@ from pkgmgr.core.git.commands import (
|
|||||||
add_remote_push_url,
|
add_remote_push_url,
|
||||||
set_remote_url,
|
set_remote_url,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
|
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
|
||||||
|
|
||||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||||
@@ -38,13 +37,10 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def build_default_ssh_url(repo: Repository) -> Optional[str]:
|
def build_default_ssh_url(repo: Repository) -> str | None:
|
||||||
provider = repo.get("provider")
|
provider = repo.get("provider")
|
||||||
account = repo.get("account")
|
account = repo.get("account")
|
||||||
name = repo.get("repository")
|
name = repo.get("repository")
|
||||||
@@ -66,7 +62,7 @@ def _git_mirrors_only(m: MirrorMap) -> MirrorMap:
|
|||||||
def determine_primary_remote_url(
|
def determine_primary_remote_url(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
ctx: RepoMirrorContext,
|
ctx: RepoMirrorContext,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Priority order (GIT URLS ONLY):
|
Priority order (GIT URLS ONLY):
|
||||||
1. origin from resolved mirrors (if it is a git URL)
|
1. origin from resolved mirrors (if it is a git URL)
|
||||||
@@ -80,7 +76,7 @@ def determine_primary_remote_url(
|
|||||||
return origin
|
return origin
|
||||||
|
|
||||||
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
|
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
|
||||||
for _, url in mirrors.items():
|
for url in mirrors.values():
|
||||||
if url and _is_git_remote_url(url):
|
if url and _is_git_remote_url(url):
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@@ -116,7 +112,7 @@ def _ensure_additional_push_urls(
|
|||||||
Non-git URLs (like PyPI) are ignored and will never land in git config.
|
Non-git URLs (like PyPI) are ignored and will never land in git config.
|
||||||
"""
|
"""
|
||||||
git_only = _git_mirrors_only(mirrors)
|
git_only = _git_mirrors_only(mirrors)
|
||||||
desired: Set[str] = {u for u in git_only.values() if u and u != primary}
|
desired: set[str] = {u for u in git_only.values() if u and u != primary}
|
||||||
if not desired:
|
if not desired:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
|||||||
return mirrors
|
return mirrors
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for line in fh:
|
for line in fh:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .printing import print_header, print_named_mirrors
|
from .printing import print_header, print_named_mirrors
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
|
|
||||||
def list_mirrors(
|
def list_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
source: str = "all",
|
source: str = "all",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, Tuple, Optional
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -11,13 +10,12 @@ from .context import build_context
|
|||||||
from .io import write_mirrors_file
|
from .io import write_mirrors_file
|
||||||
from .types import MirrorMap, Repository
|
from .types import MirrorMap, Repository
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
def _repo_key(repo: Repository) -> tuple[str, str, str]:
|
||||||
"""
|
"""
|
||||||
Normalised key for identifying a repository in config files.
|
Normalised key for identifying a repository in config files.
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +26,7 @@ def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_user_config(path: str) -> Dict[str, object]:
|
def _load_user_config(path: str) -> dict[str, object]:
|
||||||
"""
|
"""
|
||||||
Load a user config YAML file as dict.
|
Load a user config YAML file as dict.
|
||||||
Non-dicts yield {}.
|
Non-dicts yield {}.
|
||||||
@@ -37,10 +35,10 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
except Exception:
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,13 +48,13 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def merge_mirrors(
|
def merge_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
source: str,
|
source: str,
|
||||||
target: str,
|
target: str,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
user_config_path: Optional[str] = None,
|
user_config_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Merge mirrors between config and MIRRORS file.
|
Merge mirrors between config and MIRRORS file.
|
||||||
@@ -74,8 +72,8 @@ def merge_mirrors(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Load user config once if we intend to write to it.
|
# Load user config once if we intend to write to it.
|
||||||
user_cfg: Optional[Dict[str, object]] = None
|
user_cfg: dict[str, object] | None = None
|
||||||
user_cfg_path_expanded: Optional[str] = None
|
user_cfg_path_expanded: str | None = None
|
||||||
|
|
||||||
if target == "config" and user_config_path and not preview:
|
if target == "config" and user_config_path and not preview:
|
||||||
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
||||||
@@ -130,7 +128,7 @@ def merge_mirrors(
|
|||||||
|
|
||||||
repos = user_cfg.get("repositories")
|
repos = user_cfg.get("repositories")
|
||||||
target_key = _repo_key(repo)
|
target_key = _repo_key(repo)
|
||||||
existing_repo: Optional[Repository] = None
|
existing_repo: Repository | None = None
|
||||||
|
|
||||||
# Find existing repo entry
|
# Find existing repo entry
|
||||||
for entry in repos:
|
for entry in repos:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
||||||
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ def ensure_remote_repository_for_url(
|
|||||||
def ensure_remote_repository(
|
def ensure_remote_repository(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
@@ -23,9 +21,7 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_hint_from_host(host: str) -> str | None:
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
@@ -89,7 +85,7 @@ def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
|
|||||||
def _setup_local_mirrors_for_repo(
|
def _setup_local_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
@@ -106,7 +102,7 @@ def _setup_local_mirrors_for_repo(
|
|||||||
def _setup_remote_mirrors_for_repo(
|
def _setup_remote_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
ensure_remote: bool,
|
ensure_remote: bool,
|
||||||
ensure_visibility: str | None,
|
ensure_visibility: str | None,
|
||||||
@@ -195,9 +191,9 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
|
|
||||||
|
|
||||||
def setup_mirrors(
|
def setup_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
local: bool = True,
|
local: bool = True,
|
||||||
remote: bool = True,
|
remote: bool = True,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = dict[str, Any]
|
||||||
MirrorMap = Dict[str, str]
|
MirrorMap = dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
|
def hostport_from_git_url(url: str) -> tuple[str, str | None]:
|
||||||
url = (url or "").strip()
|
url = (url or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return "", None
|
return "", None
|
||||||
@@ -58,7 +57,7 @@ def _strip_dot_git(name: str) -> str:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def parse_repo_from_git_url(url: str) -> Tuple[str, Optional[str], Optional[str]]:
|
def parse_repo_from_git_url(url: str) -> tuple[str, str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Parse (host, owner, repo_name) from common Git remote URLs.
|
Parse (host, owner, repo_name) from common Git remote URLs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
|
|
||||||
@@ -20,9 +18,7 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_hint_from_host(host: str) -> str | None:
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
@@ -66,9 +62,9 @@ def _apply_visibility_for_url(
|
|||||||
|
|
||||||
|
|
||||||
def set_mirror_visibility(
|
def set_mirror_visibility(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
*,
|
*,
|
||||||
visibility: str,
|
visibility: str,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
|
||||||
from pkgmgr.core.command.run import run_command
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from pkgmgr.core.command.run import run_command
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
|
||||||
|
|
||||||
def exec_proxy_command(
|
def exec_proxy_command(
|
||||||
proxy_prefix: str,
|
proxy_prefix: str,
|
||||||
|
|||||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user