Compare commits
41
Commits
1f6fe52855
...
v2.5.0
No files matched your search
@@ -15,12 +15,12 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run hadolint (produce SARIF)
|
||||
id: hadolint
|
||||
continue-on-error: true
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
|
||||
with:
|
||||
dockerfile: ./Dockerfile
|
||||
format: sarif
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
lint-shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install ShellCheck
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: Run ShellCheck
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true # We need tags and main history for version comparison
|
||||
|
||||
@@ -4,56 +4,118 @@ on:
|
||||
workflow_run:
|
||||
workflows: ["Mark stable commit"]
|
||||
types: [completed]
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
prepare:
|
||||
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 repository (with tags)
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout ${{ matrix.release.version }}
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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
|
||||
ref: ${{ matrix.release.sha }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish all images
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
- name: Build and push ${{ matrix.distro }} ${{ matrix.release.version }} for ${{ matrix.target.platform }}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
VERSION: ${{ steps.info.outputs.version }}
|
||||
IS_STABLE: ${{ steps.info.outputs.is_stable }}
|
||||
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
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: *distros
|
||||
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: Join both platforms of ${{ matrix.distro }} ${{ matrix.release.version }} under its tags
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
VERSION: ${{ matrix.release.version }}
|
||||
ALIASES: ${{ matrix.release.aliases }}
|
||||
PKGMGR_DISTRO: ${{ matrix.distro }}
|
||||
run: bash scripts/github/publish-containers/merge-container-images.sh
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show commit SHA
|
||||
run: git rev-parse HEAD
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v23
|
||||
|
||||
@@ -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.
|
||||
+136
@@ -1,5 +1,141 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.0] - 2026-11-13
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- Review: expand and collapse are icon buttons with labels
|
||||
|
||||
Fixed:
|
||||
|
||||
- GC: merged local-only branches are removed without a forge token
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -70,6 +70,90 @@ 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 cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.<name>` section in the repository's own config file (`git config --file <git common dir>/config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `<common>/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `<common>/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<dir> symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch.<name>.deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `<common>/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item (also one that exists only on a remote). Meant for working clones and their worktrees, not for a bare server repository. Not mirrored from `git branch --delete-merged`: git also keeps a branch whose push would update its own upstream; `--local` does not. A branch is deleted before its planned local upstream; right before each delete its holds and then itLine truncated
|
||||
| `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 🗺️
|
||||
|
||||
The following diagram gives a full overview of:
|
||||
@@ -181,16 +265,50 @@ is a manifest list covering `linux/amd64` and `linux/arm64`.
|
||||
|
||||
All images live under `ghcr.io/kevinveenbirkenbach/`:
|
||||
|
||||
| Tag | Contains |
|
||||
| --------------------------- | ---------------------------------- |
|
||||
| `pkgmgr-<distro>:latest` | newest build for that distribution |
|
||||
| `pkgmgr-<distro>:<version>` | one release |
|
||||
| `pkgmgr-<distro>:stable` | newest release marked stable |
|
||||
| Tag | Contains |
|
||||
| ------------------------------------- | --------------------------------------------------------- |
|
||||
| `pkgmgr-<distro>:latest` | newest build of the newest release on `main` |
|
||||
| `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 |
|
||||
|
||||
`<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.
|
||||
|
||||
`<version>` is the PKGMGR release and `<unix-ts>` the build time, so
|
||||
`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:
|
||||
|
||||
- the newest `vX.Y.Z` release on `main`, moving `latest`, its prefixes and
|
||||
`<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.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "2.0.2";
|
||||
version = "2.5.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=2.0.2
|
||||
pkgver=2.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,157 @@
|
||||
package-manager (2.5.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- Review: expand and collapse are icon buttons with labels
|
||||
|
||||
Fixed:
|
||||
|
||||
- GC: merged local-only branches are removed without a forge token
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 13 Nov 2026 22:53:12 +0000
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 2.0.2
|
||||
Version: 2.5.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,133 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Fri Nov 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.5.0-1
|
||||
- Added:
|
||||
|
||||
- - Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- - Review: expand and collapse are icon buttons with labels
|
||||
|
||||
- Fixed:
|
||||
|
||||
- - GC: merged local-only branches are removed without a forge token
|
||||
|
||||
* 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.
|
||||
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "2.0.2"
|
||||
version = "2.5.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -21,7 +21,7 @@ authors = [
|
||||
dependencies = [
|
||||
"PyYAML>=6.0",
|
||||
"tomli; python_version < \"3.11\"",
|
||||
"jinja2>=3.1"
|
||||
"jinja2>=3.1.6"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -52,3 +52,4 @@ include = ["pkgmgr*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||
"pkgmgr.actions.review" = ["*.j2", "*.LICENSE"]
|
||||
@@ -30,3 +30,24 @@ resolve_platforms() {
|
||||
*) 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
|
||||
}
|
||||
+136
-32
@@ -12,12 +12,15 @@ NO_CACHE=0
|
||||
MISSING_ONLY=0
|
||||
IMAGE_TAG="" # local image name or base tag (without registry)
|
||||
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
|
||||
OWNER="" # e.g. github org/user
|
||||
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
|
||||
VERSION="" # X.Y.Z (required for --publish)
|
||||
IS_STABLE="false" # "true" -> publish stable tags
|
||||
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"
|
||||
|
||||
usage() {
|
||||
@@ -33,15 +36,24 @@ Build options:
|
||||
|
||||
Publish options:
|
||||
--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)
|
||||
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
|
||||
--repo-prefix <name> Image base name (default: pkgmgr)
|
||||
--version <X.Y.Z> Version for --publish
|
||||
--stable <true|false> Whether to publish :stable tags (default: false)
|
||||
--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:
|
||||
- --publish implies --push and requires --registry, --owner, and --version.
|
||||
- --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
|
||||
}
|
||||
@@ -77,11 +89,18 @@ while [[ $# -gt 0 ]]; do
|
||||
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--stable)
|
||||
IS_STABLE="${2:-}"
|
||||
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
|
||||
--aliases)
|
||||
[[ $# -ge 2 ]] || { echo "ERROR: --aliases requires a value, empty for none"; exit 2; }
|
||||
ALIASES="$2"
|
||||
ALIASES_SET=1
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="${2:-}"
|
||||
[[ -n "${PLATFORM}" ]] || { echo "ERROR: --platform requires a value"; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--merge) MERGE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
@@ -115,8 +134,24 @@ if [[ "${PUBLISH}" == "1" ]]; then
|
||||
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
|
||||
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; 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
|
||||
|
||||
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
|
||||
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
|
||||
if [[ "${IMAGE_TAG}" != */* ]]; then
|
||||
@@ -130,7 +165,7 @@ echo "------------------------------------------------------------"
|
||||
echo "[build] Building image"
|
||||
echo "distro = ${PKGMGR_DISTRO}"
|
||||
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
||||
echo "platforms = $(resolve_platforms "$PKGMGR_DISTRO")"
|
||||
echo "platforms = ${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}"
|
||||
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
||||
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
@@ -138,7 +173,8 @@ if [[ "${PUBLISH}" == "1" ]]; then
|
||||
echo "registry = ${REGISTRY}"
|
||||
echo "owner = ${OWNER}"
|
||||
echo "version = ${VERSION}"
|
||||
echo "stable = ${IS_STABLE}"
|
||||
echo "stamp = ${STAMP}"
|
||||
echo "aliases = ${ALIASES}"
|
||||
fi
|
||||
echo "------------------------------------------------------------"
|
||||
|
||||
@@ -149,45 +185,113 @@ if [[ "${NO_CACHE}" == "1" ]]; then
|
||||
build_args+=(--no-cache)
|
||||
fi
|
||||
|
||||
compute_publish_tags() {
|
||||
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||
local alias_tag_base=""
|
||||
|
||||
publish_bases() {
|
||||
echo "${DISTRO_TAG_BASE}"
|
||||
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
||||
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
||||
echo "${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
local tags=()
|
||||
tags+=("${distro_tag_base}:latest")
|
||||
tags+=("${distro_tag_base}:${VERSION}")
|
||||
|
||||
if [[ "${IS_STABLE}" == "true" ]]; then
|
||||
tags+=("${distro_tag_base}:stable")
|
||||
if ((${#tags[@]} == 0)); then
|
||||
echo "[build] ${PKGMGR_DISTRO} ${VERSION}: every tag already joins these platform images, nothing to push."
|
||||
exit 0
|
||||
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")
|
||||
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
|
||||
|
||||
printf '%s\n' "${tags[@]}"
|
||||
}
|
||||
|
||||
if [[ "${PUSH}" == "1" ]]; then
|
||||
bx_args=(docker buildx build --push)
|
||||
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
|
||||
bx_args+=(-t "${DISTRO_TAG_BASE}:${VERSION}-${PLATFORM#*/}")
|
||||
elif [[ "${PUBLISH}" == "1" ]]; then
|
||||
while IFS= read -r t; do
|
||||
bx_args+=(-t "$t")
|
||||
done < <(compute_publish_tags)
|
||||
done < <(moving_tags)
|
||||
while IFS= read -r t; do
|
||||
bx_args+=(-t "$t")
|
||||
done < <(stamp_tags)
|
||||
else
|
||||
bx_args+=(-t "${IMAGE_TAG}")
|
||||
fi
|
||||
|
||||
bx_args+=(--platform "$(resolve_platforms "$PKGMGR_DISTRO")")
|
||||
bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
|
||||
bx_args+=("${build_args[@]}")
|
||||
bx_args+=(.)
|
||||
|
||||
|
||||
@@ -6,25 +6,28 @@ set -euo pipefail
|
||||
# Required env:
|
||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||
# 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:
|
||||
# REGISTRY (default: ghcr.io)
|
||||
# IS_STABLE (default: false)
|
||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# shellcheck source=./scripts/build/base.sh
|
||||
source "${SCRIPT_DIR}/base.sh"
|
||||
|
||||
REGISTRY="${REGISTRY:-ghcr.io}"
|
||||
IS_STABLE="${IS_STABLE:-false}"
|
||||
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
|
||||
|
||||
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
|
||||
: "${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] OWNER=${OWNER}"
|
||||
echo "[publish] VERSION=${VERSION}"
|
||||
echo "[publish] IS_STABLE=${IS_STABLE}"
|
||||
echo "[publish] ALIASES=${ALIASES}"
|
||||
echo "[publish] DISTROS=${DISTROS}"
|
||||
|
||||
for d in ${DISTROS}; do
|
||||
@@ -33,12 +36,23 @@ for d in ${DISTROS}; do
|
||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||
echo "============================================================"
|
||||
|
||||
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")"
|
||||
for p in "${platforms[@]}"; do
|
||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--platform "${p}"
|
||||
done
|
||||
|
||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||
--publish \
|
||||
--registry "${REGISTRY}" \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--stable "${IS_STABLE}"
|
||||
--aliases "${ALIASES}" \
|
||||
--merge
|
||||
done
|
||||
|
||||
echo
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
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)"
|
||||
if [[ -z "${V_TAG}" ]]; then
|
||||
echo "No version tag found for ${SHA}. Skipping publish."
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
# shellcheck source=scripts/build/base.sh
|
||||
source "${SCRIPT_DIR}/../../build/base.sh"
|
||||
|
||||
: "${WORKFLOW_RUN_SHA?WORKFLOW_RUN_SHA must be set, empty for a rebuild}"
|
||||
: "${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
|
||||
|
||||
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)"
|
||||
IS_STABLE=false
|
||||
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
||||
json=""
|
||||
for version in "${targets[@]}"; do
|
||||
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 "should_publish=true"
|
||||
echo "version=${VERSION}"
|
||||
echo "is_stable=${IS_STABLE}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "releases=[${json}]" >> "${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}"
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
: "${IS_STABLE:?IS_STABLE must be set}"
|
||||
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
|
||||
: "${PLATFORM:?PLATFORM must be set}"
|
||||
|
||||
bash scripts/build/publish.sh
|
||||
bash scripts/build/image.sh \
|
||||
--publish \
|
||||
--registry ghcr.io \
|
||||
--owner "${OWNER}" \
|
||||
--version "${VERSION}" \
|
||||
--platform "${PLATFORM}"
|
||||
@@ -4,10 +4,16 @@ Public API for branch actions.
|
||||
|
||||
from .close_branch import close_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__ = [
|
||||
"branch_purge",
|
||||
"branch_tree",
|
||||
"cleanup",
|
||||
"close_branch",
|
||||
"drop_branch",
|
||||
"open_branch",
|
||||
"refresh_tree_entry",
|
||||
]
|
||||
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)
|
||||
@@ -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")
|
||||
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"])
|
||||
@@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .cargo import update_cargo_version
|
||||
from .changelog_md import update_changelog
|
||||
from .debian import _get_debian_author, update_debian_changelog
|
||||
from .editor import _open_editor_for_changelog
|
||||
@@ -22,6 +23,7 @@ from .rpm_spec import update_spec_version
|
||||
__all__ = [
|
||||
"_get_debian_author",
|
||||
"_open_editor_for_changelog",
|
||||
"update_cargo_version",
|
||||
"update_changelog",
|
||||
"update_debian_changelog",
|
||||
"update_flake_version",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _write(path: str, content: str) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def update_cargo_version(
|
||||
repo_root: str, new_version: str, preview: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Set the root crate's version in Cargo.toml and its entry in Cargo.lock.
|
||||
|
||||
Args:
|
||||
repo_root: repository root that may hold Cargo.toml and Cargo.lock.
|
||||
new_version: release version without the leading "v".
|
||||
preview: only print what would change.
|
||||
|
||||
Returns:
|
||||
The paths that were (or in preview would be) changed.
|
||||
"""
|
||||
toml_path = os.path.join(repo_root, "Cargo.toml")
|
||||
if not os.path.exists(toml_path):
|
||||
print("[INFO] No Cargo.toml found. Skipping Cargo version update.")
|
||||
return []
|
||||
|
||||
toml = _read(toml_path)
|
||||
block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml)
|
||||
if not block:
|
||||
print("[INFO] Cargo.toml has no [package] section. Skipping.")
|
||||
return []
|
||||
|
||||
name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0))
|
||||
if not name:
|
||||
raise RuntimeError("Missing name key in [package] section of Cargo.toml")
|
||||
new_block, count = re.subn(
|
||||
r'(?m)^(\s*version\s*=\s*")[^"]+(")',
|
||||
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||
block.group(0),
|
||||
count=1,
|
||||
)
|
||||
if count == 0:
|
||||
raise RuntimeError("Missing version key in [package] section of Cargo.toml")
|
||||
changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]}
|
||||
|
||||
lock_path = os.path.join(repo_root, "Cargo.lock")
|
||||
if os.path.exists(lock_path):
|
||||
lock, count = re.subn(
|
||||
rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n'
|
||||
r'version = ")[^"]+("\n)(?!source = )',
|
||||
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||
_read(lock_path),
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError(
|
||||
f"Expected one local {name.group(1)!r} package in Cargo.lock, "
|
||||
f"found {count}"
|
||||
)
|
||||
changes[lock_path] = lock
|
||||
|
||||
for path, content in changes.items():
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would update {path} version to {new_version}")
|
||||
else:
|
||||
_write(path, content)
|
||||
print(f"Updated {path} version to {new_version}")
|
||||
return list(changes)
|
||||
@@ -6,10 +6,13 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
|
||||
_HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
||||
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||
_LOCATION = re.compile(r"^.*?:\d+(?::\d+)?\s+")
|
||||
_NON_DASH_ITEM = re.compile(r"^\s*[*+]\s")
|
||||
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||
_ENTRY_LEVEL = 3
|
||||
|
||||
@@ -59,6 +62,35 @@ def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
|
||||
return _builtin_lint(document)
|
||||
|
||||
|
||||
def lint_changelog_context(reference_path: str, existing: str, merged: str) -> list[str]:
|
||||
"""Return the findings *merged* has beyond those *existing* already has.
|
||||
|
||||
Args:
|
||||
reference_path: path of the target CHANGELOG.md; its directory supplies
|
||||
the markdownlint config.
|
||||
existing: the target file before the entry is inserted.
|
||||
merged: the target file with the entry inserted.
|
||||
|
||||
Returns:
|
||||
Findings of *merged* that *existing* does not produce. Empty when
|
||||
``markdownlint-cli2`` is unavailable.
|
||||
"""
|
||||
if not shutil.which("markdownlint-cli2"):
|
||||
return []
|
||||
known = Counter(
|
||||
_LOCATION.sub("", finding)
|
||||
for finding in (_markdownlint(reference_path, existing) if existing.strip() else [])
|
||||
)
|
||||
new: list[str] = []
|
||||
for finding in _markdownlint(reference_path, merged):
|
||||
key = _LOCATION.sub("", finding)
|
||||
if known[key]:
|
||||
known[key] -= 1
|
||||
else:
|
||||
new.append(finding)
|
||||
return new
|
||||
|
||||
|
||||
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
@@ -69,7 +101,7 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(document)
|
||||
proc = subprocess.run(
|
||||
["markdownlint-cli2", name],
|
||||
["markdownlint-cli2", "--no-globs", name],
|
||||
cwd=directory,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -96,6 +128,8 @@ def _builtin_lint(document: str) -> list[str]:
|
||||
errors.append(f"line {number}: trailing whitespace (MD009)")
|
||||
if "`" in line:
|
||||
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
||||
if _NON_DASH_ITEM.match(line):
|
||||
errors.append(f"line {number}: list items use '-' (MD004)")
|
||||
if "\n\n\n" in document:
|
||||
errors.append("multiple consecutive blank lines (MD012)")
|
||||
return errors
|
||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
|
||||
from .changelog_lint import (
|
||||
ChangelogLintError,
|
||||
lint_changelog_context,
|
||||
lint_changelog_entry,
|
||||
transform_changelog_message,
|
||||
)
|
||||
@@ -68,7 +69,7 @@ def update_changelog(
|
||||
missing) and above any existing release entries, so the result stays
|
||||
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
|
||||
"""
|
||||
today = date.today().isoformat() # noqa: DTZ011 - changelog dates are local calendar dates
|
||||
today = datetime.now().astimezone().date().isoformat()
|
||||
|
||||
def _entry_for(raw: str) -> tuple[str, str]:
|
||||
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
|
||||
@@ -86,9 +87,22 @@ def update_changelog(
|
||||
except EOFError:
|
||||
print("[INFO] Re-opening the editor so you can fix the entry...")
|
||||
|
||||
changelog = ""
|
||||
if os.path.exists(changelog_path):
|
||||
try:
|
||||
with open(changelog_path, encoding="utf-8") as f:
|
||||
changelog = f.read()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||
|
||||
def _findings_for(entry: str) -> list[str]:
|
||||
return lint_changelog_entry(changelog_path, entry) or lint_changelog_context(
|
||||
changelog_path, changelog, _insert_after_h1(changelog, entry)
|
||||
)
|
||||
|
||||
if message is not None:
|
||||
body, entry = _entry_for(message)
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
findings = _findings_for(entry)
|
||||
if findings:
|
||||
_print_findings(findings)
|
||||
raise ChangelogLintError(
|
||||
@@ -106,7 +120,7 @@ def update_changelog(
|
||||
)
|
||||
raw = _open_editor_for_changelog(attempt, rejected)
|
||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
findings = _findings_for(entry)
|
||||
if not findings:
|
||||
break
|
||||
_print_findings(findings)
|
||||
@@ -114,14 +128,6 @@ def update_changelog(
|
||||
rejected = findings
|
||||
_wait_for_reader()
|
||||
|
||||
changelog = ""
|
||||
if os.path.exists(changelog_path):
|
||||
try:
|
||||
with open(changelog_path, encoding="utf-8") as f:
|
||||
changelog = f.read()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||
|
||||
new_changelog = _insert_after_h1(changelog, entry)
|
||||
|
||||
print("\n================ CHANGELOG ENTRY ================")
|
||||
|
||||
@@ -3,6 +3,11 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
|
||||
def update_pyproject_version(
|
||||
pyproject_path: str, new_version: str, preview: bool = False
|
||||
@@ -18,6 +23,11 @@ def update_pyproject_version(
|
||||
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
||||
return
|
||||
|
||||
project = tomllib.loads(content).get("project")
|
||||
if project is None or "version" in project.get("dynamic", []):
|
||||
print("[INFO] pyproject.toml declares no static version, skipping.")
|
||||
return
|
||||
|
||||
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
|
||||
if not m:
|
||||
raise RuntimeError("Missing [project] section in pyproject.toml")
|
||||
|
||||
@@ -10,7 +10,7 @@ the side effects downstream are incomplete.
|
||||
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||
release can be re-pushed without touching code or recreating tags:
|
||||
|
||||
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||
* push the branch and the existing HEAD tag through `pkgmgr push`'s leases
|
||||
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||
|
||||
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||
@@ -23,7 +23,8 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.actions.repository.push import push_branch
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
@@ -50,7 +51,7 @@ def retry_release(
|
||||
tag = max(tags, key=SemVer.parse)
|
||||
print(f"Re-pushing existing tag: {tag}")
|
||||
|
||||
run(["push", "origin", branch, tag], preview=preview)
|
||||
push_branch(".", [], sign=True, refs=(tag,), preview=preview)
|
||||
|
||||
try:
|
||||
if is_highest_version_tag(tag):
|
||||
|
||||
@@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
@@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import (
|
||||
bump_patch,
|
||||
find_latest_version,
|
||||
)
|
||||
from pkgmgr.core.version.source import (
|
||||
read_cargo_version,
|
||||
read_changelog_version,
|
||||
read_debian_changelog_version,
|
||||
read_flake_version,
|
||||
read_pkgbuild_version,
|
||||
read_pyproject_version,
|
||||
read_spec_version,
|
||||
)
|
||||
|
||||
FILE_SOURCES = {
|
||||
"pyproject.toml": read_pyproject_version,
|
||||
"CHANGELOG.md": read_changelog_version,
|
||||
"flake.nix": read_flake_version,
|
||||
"PKGBUILD": read_pkgbuild_version,
|
||||
"RPM spec": read_spec_version,
|
||||
"debian/changelog": read_debian_changelog_version,
|
||||
"Cargo.toml": read_cargo_version,
|
||||
}
|
||||
_CORE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)")
|
||||
|
||||
|
||||
def determine_current_version() -> SemVer:
|
||||
def _file_versions(repo_dir: str) -> dict[str, SemVer]:
|
||||
versions: dict[str, SemVer] = {}
|
||||
for source, read in FILE_SOURCES.items():
|
||||
match = _CORE_VERSION.match(read(repo_dir) or "")
|
||||
if match:
|
||||
versions[source] = SemVer.parse(match.group(1))
|
||||
return versions
|
||||
|
||||
|
||||
def determine_current_version(repo_dir: str) -> SemVer:
|
||||
"""
|
||||
Determine the current semantic version from Git tags.
|
||||
Determine the highest version across the latest SemVer tag and every
|
||||
version file the release writes.
|
||||
|
||||
Behaviour:
|
||||
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
|
||||
- Otherwise, use the latest SemVer tag as current version.
|
||||
Args:
|
||||
repo_dir: repository root holding the version files.
|
||||
|
||||
Returns:
|
||||
The highest version found, or 0.0.0 when no source has one.
|
||||
"""
|
||||
tags = get_tags()
|
||||
if not tags:
|
||||
versions = _file_versions(repo_dir)
|
||||
latest = find_latest_version(get_tags() or [])
|
||||
if latest is not None:
|
||||
versions["tag"] = latest[1]
|
||||
if not versions:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
latest = find_latest_version(tags)
|
||||
if latest is None:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
_tag, ver = latest
|
||||
return ver
|
||||
highest = max(versions.values())
|
||||
if len(set(versions.values())) > 1:
|
||||
found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
|
||||
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
|
||||
return highest
|
||||
|
||||
|
||||
def bump_semver(current: SemVer, release_type: str) -> SemVer:
|
||||
|
||||
@@ -4,12 +4,18 @@ import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.actions.repository.push import (
|
||||
push_branch,
|
||||
refuse_unsafe_config,
|
||||
sign_unpushed,
|
||||
)
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.commands import add, commit, tag_annotated
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.repository.paths import resolve_repo_paths
|
||||
|
||||
from .files import (
|
||||
update_cargo_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_flake_version,
|
||||
@@ -45,10 +51,12 @@ def _release_impl(
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
refuse_unsafe_config(".")
|
||||
# Pull BEFORE making any modifications
|
||||
ensure_clean_and_synced(preview=preview)
|
||||
|
||||
current_ver = determine_current_version()
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
current_ver = determine_current_version(repo_root)
|
||||
new_ver = bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
@@ -58,7 +66,6 @@ def _release_impl(
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
paths = resolve_repo_paths(repo_root)
|
||||
|
||||
# --- Update versioned files ------------------------------------------------
|
||||
@@ -74,6 +81,8 @@ def _release_impl(
|
||||
|
||||
update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
|
||||
|
||||
cargo_files = update_cargo_version(repo_root, new_ver_str, preview=preview)
|
||||
|
||||
if paths.arch_pkgbuild:
|
||||
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
|
||||
else:
|
||||
@@ -124,6 +133,7 @@ def _release_impl(
|
||||
paths.arch_pkgbuild,
|
||||
paths.rpm_spec,
|
||||
paths.debian_changelog,
|
||||
*cargo_files,
|
||||
]
|
||||
existing_files = [
|
||||
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
|
||||
@@ -132,8 +142,9 @@ def _release_impl(
|
||||
if preview:
|
||||
add(existing_files, preview=True)
|
||||
commit(commit_msg, all=True, preview=True)
|
||||
sign_unpushed(".", preview=True)
|
||||
tag_annotated(new_tag, tag_msg, preview=True)
|
||||
run(["push", "origin", branch, new_tag], preview=True)
|
||||
push_branch(".", [], sign=True, refs=(new_tag,), preview=True)
|
||||
|
||||
if is_highest_version_tag(new_tag):
|
||||
update_latest_tag(new_tag, preview=True)
|
||||
@@ -153,10 +164,10 @@ def _release_impl(
|
||||
|
||||
add(existing_files, preview=False)
|
||||
commit(commit_msg, all=True, preview=False)
|
||||
sign_unpushed(".", preview=False)
|
||||
tag_annotated(new_tag, tag_msg, preview=False)
|
||||
|
||||
# Push branch and ONLY the newly created version tag in one command (no --tags)
|
||||
run(["push", "origin", branch, new_tag], preview=False)
|
||||
push_branch(".", [], sign=True, refs=(new_tag,), preview=False)
|
||||
|
||||
# Update 'latest' only if this is the highest version tag
|
||||
try:
|
||||
|
||||
@@ -1,39 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.repository._parallel import (
|
||||
RepoRef,
|
||||
resolve_repos,
|
||||
run_on_repos,
|
||||
)
|
||||
from pkgmgr.core.git.commands import GitPushArgsError, push_args
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.git.queries import UNSAFE_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import SCAN_FLAGS
|
||||
|
||||
Repository = dict[str, Any]
|
||||
|
||||
# Re-signing opens pinentry; parallel pushes must not ask for the passphrase at once.
|
||||
_SIGN_LOCK = threading.Lock()
|
||||
|
||||
def _push_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]:
|
||||
|
||||
class PushRefusedError(RuntimeError):
|
||||
"""Raised when a push would ship unsigned commits or overwrite a mirror."""
|
||||
|
||||
|
||||
def _try_git(repo_dir: str, *args: str) -> str | None:
|
||||
try:
|
||||
push_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return run(list(args), cwd=repo_dir)
|
||||
except GitRunError:
|
||||
return None
|
||||
|
||||
|
||||
def refuse_unsafe_config(repo_dir: str) -> None:
|
||||
"""
|
||||
Args:
|
||||
repo_dir: checkout about to be signed or pushed.
|
||||
|
||||
Raises:
|
||||
PushRefusedError: the repository's own config, which an agent may have
|
||||
written, sets a key that makes git run a program or reroute the push.
|
||||
Filter drivers stay allowed on purpose: git-lfs and git-crypt need
|
||||
them locally, so a clean filter can still run in the amend's index
|
||||
refresh, as it did before.
|
||||
"""
|
||||
unsafe = local_config(repo_dir, UNSAFE_CONFIG)
|
||||
if unsafe:
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: the repository config sets {', '.join(unsafe)}, which can "
|
||||
"run a program or reroute the push; unset it or move it to the global config"
|
||||
)
|
||||
|
||||
|
||||
def _push_target(repo_dir: str) -> tuple[str, str]:
|
||||
refuse_unsafe_config(repo_dir)
|
||||
branch = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir)
|
||||
if branch == "HEAD":
|
||||
raise PushRefusedError(f"{repo_dir}: detached HEAD, nothing to push")
|
||||
remote = (
|
||||
_try_git(repo_dir, "config", "--get", f"branch.{branch}.pushRemote")
|
||||
or _try_git(repo_dir, "config", "--get", "remote.pushDefault")
|
||||
or _try_git(repo_dir, "config", "--get", f"branch.{branch}.remote")
|
||||
or "origin"
|
||||
)
|
||||
return branch, remote
|
||||
|
||||
|
||||
def _unpushed_signatures(
|
||||
repo_dir: str, remote: str, branch: str
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
for ref in (
|
||||
f"refs/remotes/{remote}/{branch}",
|
||||
"@{u}",
|
||||
f"refs/remotes/{remote}/main",
|
||||
f"refs/remotes/{remote}/master",
|
||||
):
|
||||
base = _try_git(repo_dir, "merge-base", "HEAD", ref)
|
||||
if base:
|
||||
lines = run(
|
||||
["log", "--format=%H %G?", f"{base}..HEAD"], cwd=repo_dir
|
||||
).splitlines()
|
||||
return base, dict(line.split() for line in lines)
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: no {remote}/{branch}, no upstream and no {remote}/main or "
|
||||
f"{remote}/master to sign against"
|
||||
)
|
||||
|
||||
|
||||
def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
|
||||
"""
|
||||
Args:
|
||||
repo_dir: checkout whose current branch is signed.
|
||||
preview: print the fetch and the amend instead of running them.
|
||||
"""
|
||||
branch, remote = _push_target(repo_dir)
|
||||
run(
|
||||
["fetch", "--quiet", "--no-recurse-submodules", "--", remote],
|
||||
cwd=repo_dir,
|
||||
preview=preview,
|
||||
)
|
||||
base, signatures = _unpushed_signatures(repo_dir, remote, branch)
|
||||
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
older = sum(1 for sha, s in signatures.items() if sha != head and s != "G")
|
||||
if older:
|
||||
print(
|
||||
f"[push] WARN {older} older unsigned commit(s) in {base[:10]}..HEAD~ of "
|
||||
f"{repo_dir} stay unsigned: signing them would rewrite history"
|
||||
)
|
||||
if signatures.get(head, "G") == "G":
|
||||
return
|
||||
if run(
|
||||
[
|
||||
*SCAN_FLAGS,
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"--no-ext-diff",
|
||||
"--ignore-submodules=none",
|
||||
"HEAD",
|
||||
],
|
||||
cwd=repo_dir,
|
||||
):
|
||||
raise PushRefusedError(f"{repo_dir}: staged changes, cannot sign HEAD")
|
||||
|
||||
cmd = [
|
||||
"git",
|
||||
*SCAN_FLAGS,
|
||||
"commit",
|
||||
"--amend",
|
||||
"--no-edit",
|
||||
"--no-verify",
|
||||
"--allow-empty",
|
||||
"-S",
|
||||
]
|
||||
print(f"[push] Signing HEAD {head[:10]} in {repo_dir}")
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}")
|
||||
return
|
||||
with _SIGN_LOCK:
|
||||
result = subprocess.run(cmd, cwd=repo_dir, check=False)
|
||||
if result.returncode != 0:
|
||||
raise PushRefusedError(f"{repo_dir}: signing HEAD {head[:10]} failed")
|
||||
|
||||
|
||||
def _lease(
|
||||
repo_dir: str,
|
||||
url: str,
|
||||
branch_ref: str,
|
||||
tracking: str | None,
|
||||
keep: tuple[str, ...],
|
||||
preview: bool,
|
||||
) -> str:
|
||||
if tracking is not None:
|
||||
sha = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) or ""
|
||||
else:
|
||||
listed = run(["ls-remote", "--", url, branch_ref], cwd=repo_dir).splitlines()
|
||||
sha = next(
|
||||
(ln.split()[0] for ln in listed if ln.split()[1:] == [branch_ref]), ""
|
||||
)
|
||||
if sha and not preview:
|
||||
run(
|
||||
[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
"--",
|
||||
url,
|
||||
branch_ref,
|
||||
],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
if not sha or preview:
|
||||
return sha
|
||||
lost: set[str] | None = None
|
||||
for tip in keep:
|
||||
plus = {
|
||||
line[2:]
|
||||
for line in run(["cherry", tip, sha], cwd=repo_dir).splitlines()
|
||||
if line.startswith("+")
|
||||
}
|
||||
lost = plus if lost is None else lost & plus
|
||||
if lost:
|
||||
raise PushRefusedError(
|
||||
f"{url}: {len(lost)} commit(s) exist only there, not overwritten: "
|
||||
f"{' '.join(s[:10] for s in sorted(lost))}; "
|
||||
"pass --rewrite for a deliberate history rewrite"
|
||||
)
|
||||
return sha
|
||||
|
||||
|
||||
def push_branch(
|
||||
repo_dir: str,
|
||||
extra_args: list[str],
|
||||
*,
|
||||
sign: bool,
|
||||
refs: tuple[str, ...] = (),
|
||||
rewrite: bool = False,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Push the current branch to every pushurl of its push remote, each with its own lease.
|
||||
|
||||
The fetch URL is leased against the remote-tracking ref, every other
|
||||
pushurl against its own branch tip; each only after `git cherry` shows it
|
||||
holds no commit the new tip lacks. A mirror failure is reported after the
|
||||
others were pushed; a canonical failure stops before any mirror.
|
||||
|
||||
Args:
|
||||
repo_dir: checkout whose current branch is pushed.
|
||||
extra_args: flags appended to every `git push`.
|
||||
sign: refuse an unpushed, unsigned HEAD; False pushes it as it is.
|
||||
refs: extra refs (e.g. a release tag) pushed to every URL.
|
||||
rewrite: replace commits of the old canonical tip; the fetch URL skips
|
||||
the cherry check, mirrors only refuse commits the old tip never had.
|
||||
preview: print the pushes instead of running them.
|
||||
"""
|
||||
branch, remote = _push_target(repo_dir)
|
||||
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
if sign:
|
||||
_base, signatures = _unpushed_signatures(repo_dir, remote, branch)
|
||||
if not preview and signatures.get(head, "G") != "G":
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: HEAD {head[:10]} is unsigned; run `pkgmgr push` to sign it"
|
||||
)
|
||||
|
||||
fetch_url = run(["remote", "get-url", "--", remote], cwd=repo_dir)
|
||||
push_urls = run(
|
||||
["remote", "get-url", "--push", "--all", "--", remote], cwd=repo_dir
|
||||
).splitlines()
|
||||
branch_ref = f"refs/heads/{branch}"
|
||||
tracking = f"refs/remotes/{remote}/{branch}"
|
||||
old_tip = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking)
|
||||
if fetch_url not in push_urls:
|
||||
print(
|
||||
f"[push] WARN {remote}: fetch URL is no pushurl; every URL is leased "
|
||||
"as a mirror, tracking ref and upstream stay untouched"
|
||||
)
|
||||
|
||||
failed: list[str] = []
|
||||
for url in sorted(push_urls, key=lambda u: u != fetch_url):
|
||||
if url == fetch_url:
|
||||
keep: tuple[str, ...] = () if rewrite else (head,)
|
||||
else:
|
||||
keep = (head, old_tip) if rewrite and old_tip else (head,)
|
||||
try:
|
||||
expect = _lease(
|
||||
repo_dir,
|
||||
url,
|
||||
branch_ref,
|
||||
tracking if url == fetch_url else None,
|
||||
keep,
|
||||
preview,
|
||||
)
|
||||
run(
|
||||
[
|
||||
"push",
|
||||
"--follow-tags",
|
||||
"--no-recurse-submodules",
|
||||
f"--force-with-lease={branch_ref}:{expect}",
|
||||
*extra_args,
|
||||
"--",
|
||||
url,
|
||||
branch_ref,
|
||||
*refs,
|
||||
],
|
||||
cwd=repo_dir,
|
||||
preview=preview,
|
||||
)
|
||||
except (GitRunError, PushRefusedError) as exc:
|
||||
if url == fetch_url:
|
||||
raise
|
||||
print(f"[push] FAILED {url}\n{exc}")
|
||||
failed.append(url)
|
||||
continue
|
||||
print(f"[push] {url} <- {branch} {head[:10]}")
|
||||
if url == fetch_url and not preview:
|
||||
run(["update-ref", tracking, head], cwd=repo_dir)
|
||||
if not _try_git(repo_dir, "rev-parse", "--abbrev-ref", "@{u}"):
|
||||
run(["branch", f"--set-upstream-to={remote}/{branch}"], cwd=repo_dir)
|
||||
|
||||
if failed:
|
||||
raise PushRefusedError(f"{repo_dir}: push failed for {', '.join(failed)}")
|
||||
|
||||
|
||||
def _push_one(
|
||||
repo_dir: str, extra_args: list[str], sign: bool, rewrite: bool, preview: bool
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
if sign:
|
||||
sign_unpushed(repo_dir, preview=preview)
|
||||
push_branch(repo_dir, extra_args, sign=sign, rewrite=rewrite, preview=preview)
|
||||
return (True, "")
|
||||
except GitPushArgsError as exc:
|
||||
except (GitRunError, PushRefusedError) as exc:
|
||||
return (False, str(exc))
|
||||
|
||||
|
||||
def push_repo_dirs(
|
||||
repos: list[RepoRef],
|
||||
extra_args: list[str],
|
||||
preview: bool,
|
||||
sign: bool,
|
||||
jobs: int = 1,
|
||||
rewrite: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Sign and push each ``(identifier, repo_dir)``, optionally in parallel.
|
||||
"""
|
||||
run_on_repos(
|
||||
repos,
|
||||
lambda rd: _push_one(rd, extra_args, sign, rewrite, preview),
|
||||
jobs=jobs,
|
||||
op_name="push",
|
||||
)
|
||||
|
||||
|
||||
def push_in_parallel(
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: list[Repository],
|
||||
extra_args: list[str],
|
||||
preview: bool,
|
||||
sign: bool,
|
||||
jobs: int = 1,
|
||||
rewrite: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` for each repository, optionally in parallel.
|
||||
Sign and push each selected repository, optionally in parallel.
|
||||
"""
|
||||
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos)
|
||||
run_on_repos(
|
||||
repos,
|
||||
lambda rd: _push_one(rd, extra_args, preview),
|
||||
push_repo_dirs(
|
||||
resolve_repos(selected_repos, repositories_base_dir, all_repos),
|
||||
extra_args,
|
||||
preview,
|
||||
sign,
|
||||
jobs=jobs,
|
||||
op_name="push",
|
||||
rewrite=rewrite,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .collect import collect_review
|
||||
from .model import ReviewError
|
||||
from .render import render_review, write_review
|
||||
|
||||
__all__ = ["ReviewError", "collect_review", "render_review", "write_review"]
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019-2024 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.branch.gc import GITLINK
|
||||
from pkgmgr.core.git.errors import GitBaseError, GitRunError
|
||||
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import run
|
||||
|
||||
from .model import FileView, ReviewError
|
||||
from .repo import git_text
|
||||
|
||||
ATTRIBUTE_BINARY = (
|
||||
"{} is shown as binary because {}, not because of its content: "
|
||||
"run git diff --text to see it."
|
||||
)
|
||||
UNSET = "a gitattributes entry unsets its diff attribute"
|
||||
DRIVER = "its gitattributes diff driver {0} sets diff.{0}.binary"
|
||||
RUNS = "a command git would run on this host"
|
||||
|
||||
|
||||
def hidden_entries(top: str) -> list[str]:
|
||||
listing = git_text(["ls-files", "-v", "-z"], top)
|
||||
return [
|
||||
f"Index entry {entry[2:]} is marked "
|
||||
f"{'skip-worktree' if entry[0] == 'S' else 'assume-unchanged'}: "
|
||||
"its worktree changes are not shown."
|
||||
for entry in listing.split("\0")
|
||||
if entry[:1] == "S" or entry[:1].islower()
|
||||
]
|
||||
|
||||
|
||||
def _binary_drivers(top: str) -> set[str]:
|
||||
try:
|
||||
out = run(
|
||||
["config", "-z", "--type=bool", "--get-regexp", r"^diff\..*\.binary$"],
|
||||
cwd=top,
|
||||
scan=True,
|
||||
)
|
||||
except GitRunError as exc:
|
||||
if exc.returncode == 1:
|
||||
return set()
|
||||
raise ReviewError("git config failed reading diff.<driver>.binary") from None
|
||||
last: dict[str, str] = {}
|
||||
for record in filter(None, out.split("\0")):
|
||||
key, _, value = record.partition("\n")
|
||||
last[key.removeprefix("diff.").removesuffix(".binary")] = value
|
||||
return {driver for driver, value in last.items() if value == "true"}
|
||||
|
||||
|
||||
def attribute_binary(files: list[FileView], top: str) -> list[str]:
|
||||
paths = list(dict.fromkeys(view.diff.path for view in files if view.diff.binary))
|
||||
if not paths:
|
||||
return []
|
||||
fields = git_text(["check-attr", "-z", "diff", "--", *paths], top).split("\0")
|
||||
reasons = {
|
||||
**{driver: DRIVER.format(driver) for driver in _binary_drivers(top)},
|
||||
"unset": UNSET,
|
||||
}
|
||||
return [
|
||||
ATTRIBUTE_BINARY.format(path, reasons[value])
|
||||
for path, value in zip(fields[0::3], fields[2::3])
|
||||
if value in reasons
|
||||
]
|
||||
|
||||
|
||||
def _submodule_configs(top: str, prefix: str) -> list[str]:
|
||||
"""
|
||||
Args:
|
||||
top: Real path of the top-level working tree.
|
||||
prefix: Submodule path below ``top`` whose gitlinks are walked, or ""
|
||||
for the top level itself.
|
||||
|
||||
Returns:
|
||||
One reason per populated submodule, nested ones included, whose
|
||||
config defines a filter or cannot be read. A gitlink behind a symbolic
|
||||
link or without a ``.git`` is skipped: git runs nothing there either.
|
||||
"""
|
||||
listing = git_text(["ls-files", "--stage", "-z"], os.path.join(top, prefix))
|
||||
gitlinks = dict.fromkeys(
|
||||
path
|
||||
for meta, _, path in (entry.partition("\t") for entry in listing.split("\0"))
|
||||
if meta.startswith(f"{GITLINK} ")
|
||||
)
|
||||
reasons: list[str] = []
|
||||
for path in gitlinks:
|
||||
relative = os.path.join(prefix, path)
|
||||
full = os.path.join(top, relative)
|
||||
if os.path.realpath(full) != full or not os.path.lexists(
|
||||
os.path.join(full, ".git")
|
||||
):
|
||||
continue
|
||||
try:
|
||||
keys = local_config(full, FILTER_CONFIG)
|
||||
nested = _submodule_configs(top, relative)
|
||||
except (GitBaseError, ReviewError):
|
||||
reasons.append(
|
||||
f"the config of submodule {relative} cannot be read, so its "
|
||||
"filters cannot be ruled out"
|
||||
)
|
||||
continue
|
||||
if keys:
|
||||
reasons.append(
|
||||
f"the config of submodule {relative} defines {', '.join(keys)}, {RUNS}"
|
||||
)
|
||||
reasons.extend(nested)
|
||||
return reasons
|
||||
|
||||
|
||||
def unsafe_configs(top: str) -> list[str]:
|
||||
keys = local_config(top, FILTER_CONFIG)
|
||||
own = [f"the repository config defines {', '.join(keys)}, {RUNS}"] if keys else []
|
||||
return own + _submodule_configs(os.path.realpath(top), "")
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS
|
||||
|
||||
from .checks import attribute_binary, hidden_entries, unsafe_configs
|
||||
from .diff import parse_patch
|
||||
from .files import read_note, untracked_views
|
||||
from .model import Commit, Review, Section
|
||||
from .repo import base_ref, git_optional, git_text, top_level
|
||||
from .views import file_views
|
||||
|
||||
DIFF_FLAGS = [
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"-M",
|
||||
"--submodule=short",
|
||||
"--ignore-submodules=none",
|
||||
"--src-prefix=a/",
|
||||
"--dst-prefix=b/",
|
||||
]
|
||||
|
||||
|
||||
def _commit(sha: str, anchor: str, top: str, limit: int) -> Commit:
|
||||
meta = git_text(
|
||||
[
|
||||
"show",
|
||||
"-s",
|
||||
"--no-color",
|
||||
"--no-show-signature",
|
||||
"--format=%h%x00%an <%ae>%x00%s%x00%b",
|
||||
END_OF_OPTIONS,
|
||||
sha,
|
||||
],
|
||||
top,
|
||||
)
|
||||
short, author, subject, body = meta.split("\0", 3)
|
||||
patch = git_text(
|
||||
[
|
||||
"show",
|
||||
"--format=",
|
||||
"--no-show-signature",
|
||||
"--diff-merges=first-parent",
|
||||
*DIFF_FLAGS,
|
||||
END_OF_OPTIONS,
|
||||
sha,
|
||||
],
|
||||
top,
|
||||
)
|
||||
return Commit(
|
||||
anchor=anchor,
|
||||
short=short,
|
||||
author=author,
|
||||
subject=subject,
|
||||
body=body.rstrip("\n"),
|
||||
files=file_views(parse_patch(patch), anchor, top, limit),
|
||||
)
|
||||
|
||||
|
||||
def collect_review(
|
||||
cwd: str, max_file_lines: int, base: str | None, notes: list[str]
|
||||
) -> Review:
|
||||
"""
|
||||
Args:
|
||||
cwd: Directory inside the git working copy to review.
|
||||
max_file_lines: Diff lines shown per file before the rest is summarised.
|
||||
base: Branch, under refs/heads/ or refs/remotes/, whose ``REF..HEAD``
|
||||
commits are listed, or None for no Commits section.
|
||||
notes: Regular text files shown verbatim at the top, relative to
|
||||
``cwd``; a symbolic link anywhere in the path is refused.
|
||||
|
||||
Returns:
|
||||
The collected Review model.
|
||||
"""
|
||||
top = top_level(cwd)
|
||||
base_name, base_sha = (None, None) if base is None else base_ref(base, top)
|
||||
note_items = tuple(read_note(path, cwd) for path in notes)
|
||||
unsafe = unsafe_configs(top)
|
||||
skipped = (
|
||||
f"Unstaged changes and index tree not computed: {'; '.join(unsafe)}."
|
||||
if unsafe
|
||||
else None
|
||||
)
|
||||
staged = Section(
|
||||
key="staged",
|
||||
title="Staged",
|
||||
files=file_views(
|
||||
parse_patch(git_text(["diff", "--cached", *DIFF_FLAGS], top)),
|
||||
"staged",
|
||||
top,
|
||||
max_file_lines,
|
||||
),
|
||||
skipped=None,
|
||||
)
|
||||
unstaged = Section(
|
||||
key="unstaged",
|
||||
title="Unstaged",
|
||||
files=()
|
||||
if skipped
|
||||
else file_views(
|
||||
parse_patch(git_text(["diff", *DIFF_FLAGS], top)),
|
||||
"unstaged",
|
||||
top,
|
||||
max_file_lines,
|
||||
),
|
||||
skipped=skipped,
|
||||
)
|
||||
untracked = Section(
|
||||
key="untracked",
|
||||
title="Untracked",
|
||||
files=untracked_views(top, max_file_lines),
|
||||
skipped=None,
|
||||
)
|
||||
commits: tuple[Commit, ...] = ()
|
||||
if base_sha is not None:
|
||||
shas = git_text(["rev-list", END_OF_OPTIONS, f"{base_sha}..HEAD"], top)
|
||||
commits = tuple(
|
||||
_commit(sha, f"commit-{index}", top, max_file_lines)
|
||||
for index, sha in enumerate(shas.split(), 1)
|
||||
)
|
||||
tracked = [
|
||||
*staged.files,
|
||||
*unstaged.files,
|
||||
*(view for commit in commits for view in commit.files),
|
||||
]
|
||||
return Review(
|
||||
top_level=top,
|
||||
name=os.path.basename(top),
|
||||
branch=git_optional(
|
||||
["symbolic-ref", "--short", "-q", END_OF_OPTIONS, "HEAD"], top
|
||||
),
|
||||
head=git_optional(
|
||||
["rev-parse", "--verify", "--quiet", "--short", END_OF_OPTIONS, "HEAD"],
|
||||
top,
|
||||
),
|
||||
index_tree="(not computed, see the warnings)"
|
||||
if skipped
|
||||
else git_optional(["write-tree"], top) or "(index has unmerged entries)",
|
||||
base=base_name,
|
||||
base_short=None
|
||||
if base_sha is None
|
||||
else git_text(["rev-parse", "--short", END_OF_OPTIONS, base_sha], top).strip(),
|
||||
warnings=tuple(
|
||||
([skipped] if skipped else [])
|
||||
+ hidden_entries(top)
|
||||
+ attribute_binary(tracked, top)
|
||||
),
|
||||
notes=note_items,
|
||||
sections=(staged, unstaged, untracked),
|
||||
commits=commits,
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .model import FileDiff, Hunk, Line
|
||||
|
||||
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||
GIT_HEADER = "diff --git "
|
||||
ESCAPES = {
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"t": 9,
|
||||
"n": 10,
|
||||
"v": 11,
|
||||
"f": 12,
|
||||
"r": 13,
|
||||
'"': 34,
|
||||
"\\": 92,
|
||||
}
|
||||
HEADER_FIELDS = (
|
||||
"rename from ",
|
||||
"rename to ",
|
||||
"copy from ",
|
||||
"copy to ",
|
||||
"new file mode ",
|
||||
"deleted file mode ",
|
||||
"old mode ",
|
||||
"new mode ",
|
||||
"--- ",
|
||||
"+++ ",
|
||||
)
|
||||
|
||||
|
||||
def unquote(text: str) -> str:
|
||||
if len(text) < 2 or not (text.startswith('"') and text.endswith('"')):
|
||||
return text
|
||||
body = text[1:-1]
|
||||
raw = bytearray()
|
||||
index = 0
|
||||
while index < len(body):
|
||||
char = body[index]
|
||||
escaped = body[index + 1 : index + 2]
|
||||
octal = body[index + 1 : index + 4]
|
||||
if char == "\\" and escaped in ESCAPES:
|
||||
raw.append(ESCAPES[escaped])
|
||||
index += 2
|
||||
elif char == "\\" and len(octal) == 3 and set(octal) <= set("01234567"):
|
||||
raw.append(int(octal, 8) & 0xFF)
|
||||
index += 4
|
||||
else:
|
||||
raw.extend(char.encode("utf-8"))
|
||||
index += 1
|
||||
return raw.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _side(value: str, prefix: str) -> str | None:
|
||||
value = value.removesuffix("\t")
|
||||
if value == "/dev/null":
|
||||
return None
|
||||
return unquote(value).removeprefix(prefix)
|
||||
|
||||
|
||||
def _file(
|
||||
git_line: str, headers: list[str], pending: list[tuple[str, int, list[Line]]]
|
||||
) -> FileDiff:
|
||||
fields: dict[str, str] = {}
|
||||
for header in headers:
|
||||
for key in HEADER_FIELDS:
|
||||
if header.startswith(key):
|
||||
fields[key.strip()] = header[len(key) :]
|
||||
break
|
||||
half = (len(git_line) - 1) // 2
|
||||
old_path = _side(git_line[:half], "a/")
|
||||
new_path = _side(git_line[half + 1 :], "b/")
|
||||
if "---" in fields:
|
||||
old_path = _side(fields["---"], "a/")
|
||||
if "+++" in fields:
|
||||
new_path = _side(fields["+++"], "b/")
|
||||
status = "modified"
|
||||
for kind, moved in (("rename", "renamed"), ("copy", "copied")):
|
||||
if f"{kind} from" in fields and f"{kind} to" in fields:
|
||||
old_path = unquote(fields[f"{kind} from"])
|
||||
new_path = unquote(fields[f"{kind} to"])
|
||||
status = moved
|
||||
if "new file mode" in fields:
|
||||
old_path, status = None, "added"
|
||||
if "deleted file mode" in fields:
|
||||
new_path, status = None, "deleted"
|
||||
mode_change = None
|
||||
if "old mode" in fields and "new mode" in fields:
|
||||
mode_change = f"{fields['old mode']} → {fields['new mode']}"
|
||||
return FileDiff(
|
||||
old_path=old_path,
|
||||
new_path=new_path,
|
||||
status=status,
|
||||
binary=any(header.startswith("Binary files ") for header in headers),
|
||||
mode_change=mode_change,
|
||||
hunks=tuple(
|
||||
Hunk(header, start, tuple(lines)) for header, start, lines in pending
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_patch(text: str) -> list[FileDiff]:
|
||||
"""
|
||||
Args:
|
||||
text: Output of ``git diff`` or ``git show`` produced with ``a/`` and
|
||||
``b/`` prefixes and decoded without newline translation.
|
||||
|
||||
Returns:
|
||||
One FileDiff per ``diff --git`` block, in git's order.
|
||||
"""
|
||||
files: list[FileDiff] = []
|
||||
git_line: str | None = None
|
||||
headers: list[str] = []
|
||||
pending: list[tuple[str, int, list[Line]]] = []
|
||||
old_no = new_no = old_left = new_left = 0
|
||||
for raw in text.split("\n"):
|
||||
if pending and (old_left > 0 or new_left > 0):
|
||||
lines = pending[-1][2]
|
||||
sign, body = raw[:1], raw[1:]
|
||||
if sign == "+":
|
||||
lines.append(Line("add", None, new_no, body))
|
||||
new_no, new_left = new_no + 1, new_left - 1
|
||||
elif sign == "-":
|
||||
lines.append(Line("del", old_no, None, body))
|
||||
old_no, old_left = old_no + 1, old_left - 1
|
||||
elif sign == "\\":
|
||||
lines.append(Line("meta", None, None, raw))
|
||||
else:
|
||||
lines.append(Line("ctx", old_no, new_no, body))
|
||||
old_no, new_no = old_no + 1, new_no + 1
|
||||
old_left, new_left = old_left - 1, new_left - 1
|
||||
continue
|
||||
if raw.startswith(GIT_HEADER):
|
||||
if git_line is not None:
|
||||
files.append(_file(git_line, headers, pending))
|
||||
git_line, headers, pending = raw[len(GIT_HEADER) :], [], []
|
||||
continue
|
||||
if git_line is None:
|
||||
continue
|
||||
match = HUNK_HEADER.match(raw)
|
||||
if match:
|
||||
old_no, new_no = int(match[1]), int(match[3])
|
||||
old_left = 1 if match[2] is None else int(match[2])
|
||||
new_left = 1 if match[4] is None else int(match[4])
|
||||
pending.append((raw, new_no, []))
|
||||
elif pending and raw.startswith("\\"):
|
||||
pending[-1][2].append(Line("meta", None, None, raw))
|
||||
elif not pending:
|
||||
headers.append(raw)
|
||||
if git_line is not None:
|
||||
files.append(_file(git_line, headers, pending))
|
||||
return files
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
|
||||
from .model import FileDiff, FileView, Hunk, Line, Note, ReviewError
|
||||
from .repo import git_text
|
||||
from .views import BINARY_DETAIL, file_view
|
||||
|
||||
READ_MAX_BYTES = 1024 * 1024
|
||||
BINARY_SNIFF_BYTES = 8000
|
||||
|
||||
|
||||
def _listed(path: str, binary: bool) -> FileDiff:
|
||||
return FileDiff(
|
||||
old_path=None,
|
||||
new_path=path,
|
||||
status="untracked",
|
||||
binary=binary,
|
||||
mode_change=None,
|
||||
hunks=(),
|
||||
)
|
||||
|
||||
|
||||
def _read_regular(path: str, limit: int) -> tuple[bytes, int] | None:
|
||||
"""
|
||||
Args:
|
||||
path: Absolute path, opened one component at a time without following
|
||||
a symbolic link anywhere along it.
|
||||
limit: Most bytes to read.
|
||||
|
||||
Returns:
|
||||
The bytes read and the file size, or None when a component is a
|
||||
symbolic link or the target is not a regular file.
|
||||
"""
|
||||
names = [name for name in path.split(os.sep) if name not in ("", ".")]
|
||||
fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for index, name in enumerate(names, 1):
|
||||
kind = os.O_NONBLOCK if index == len(names) else os.O_DIRECTORY
|
||||
child = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | kind, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
return None
|
||||
with os.fdopen(os.dup(fd), "rb") as handle:
|
||||
return handle.read(limit), info.st_size
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||
return None
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _untracked_file(path: str, top: str) -> tuple[FileDiff, str | None]:
|
||||
try:
|
||||
read = _read_regular(os.path.join(top, path), READ_MAX_BYTES + 1)
|
||||
except OSError as exc:
|
||||
return _listed(path, False), f"Unreadable: {exc.strerror}."
|
||||
if read is None:
|
||||
return _listed(path, False), "Not a regular file, contents not shown."
|
||||
data, size = read
|
||||
if len(data) > READ_MAX_BYTES:
|
||||
return _listed(path, False), f"Large file ({size} bytes), contents not shown."
|
||||
if b"\0" in data[:BINARY_SNIFF_BYTES]:
|
||||
return _listed(path, True), BINARY_DETAIL
|
||||
lines = data.decode("utf-8", "replace").split("\n")
|
||||
if lines[-1] == "":
|
||||
lines.pop()
|
||||
added = tuple(
|
||||
Line("add", None, number, text) for number, text in enumerate(lines, 1)
|
||||
)
|
||||
hunks = (Hunk(f"@@ -0,0 +1,{len(added)} @@", 1, added),) if added else ()
|
||||
return (
|
||||
FileDiff(
|
||||
old_path=None,
|
||||
new_path=path,
|
||||
status="untracked",
|
||||
binary=False,
|
||||
mode_change=None,
|
||||
hunks=hunks,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def untracked_views(top: str, limit: int) -> tuple[FileView, ...]:
|
||||
listing = git_text(["ls-files", "--others", "--exclude-standard", "-z"], top)
|
||||
views = []
|
||||
for index, path in enumerate(sorted(p for p in listing.split("\0") if p), 1):
|
||||
diff, detail = _untracked_file(path, top)
|
||||
views.append(file_view(diff, f"untracked-{index}", top, limit, detail))
|
||||
return tuple(views)
|
||||
|
||||
|
||||
def read_note(path: str, cwd: str) -> Note:
|
||||
try:
|
||||
read = _read_regular(
|
||||
os.path.abspath(os.path.join(cwd, path)), READ_MAX_BYTES + 1
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ReviewError(f"cannot read note {path!r}: {exc.strerror}") from None
|
||||
if read is None:
|
||||
raise ReviewError(
|
||||
f"cannot read note {path!r}: a symbolic link or not a regular file"
|
||||
)
|
||||
data, size = read
|
||||
if len(data) > READ_MAX_BYTES:
|
||||
raise ReviewError(
|
||||
f"cannot read note {path!r}: {size} bytes, more than {READ_MAX_BYTES}"
|
||||
)
|
||||
return Note(title=os.path.basename(path), text=data.decode("utf-8", "replace"))
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ReviewError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line:
|
||||
kind: str
|
||||
old: int | None
|
||||
new: int | None
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hunk:
|
||||
header: str
|
||||
new_start: int
|
||||
lines: tuple[Line, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileDiff:
|
||||
old_path: str | None
|
||||
new_path: str | None
|
||||
status: str
|
||||
binary: bool
|
||||
mode_change: str | None
|
||||
hunks: tuple[Hunk, ...]
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self.new_path if self.new_path is not None else str(self.old_path)
|
||||
|
||||
@property
|
||||
def moved(self) -> bool:
|
||||
return self.status in ("renamed", "copied")
|
||||
|
||||
@property
|
||||
def added(self) -> int:
|
||||
return sum(
|
||||
1 for hunk in self.hunks for line in hunk.lines if line.kind == "add"
|
||||
)
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
return sum(
|
||||
1 for hunk in self.hunks for line in hunk.lines if line.kind == "del"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileView:
|
||||
anchor: str
|
||||
diff: FileDiff
|
||||
detail: str | None
|
||||
link_base: str | None
|
||||
link: str | None
|
||||
hunks: tuple[Hunk, ...]
|
||||
hidden: int
|
||||
|
||||
@property
|
||||
def search(self) -> str:
|
||||
if self.diff.moved:
|
||||
return f"{self.diff.old_path} {self.diff.new_path}"
|
||||
return self.diff.path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Section:
|
||||
key: str
|
||||
title: str
|
||||
files: tuple[FileView, ...]
|
||||
skipped: str | None
|
||||
|
||||
@property
|
||||
def added(self) -> int:
|
||||
return sum(view.diff.added for view in self.files)
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
return sum(view.diff.removed for view in self.files)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commit:
|
||||
anchor: str
|
||||
short: str
|
||||
author: str
|
||||
subject: str
|
||||
body: str
|
||||
files: tuple[FileView, ...]
|
||||
|
||||
@property
|
||||
def added(self) -> int:
|
||||
return sum(view.diff.added for view in self.files)
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
return sum(view.diff.removed for view in self.files)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Note:
|
||||
title: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Review:
|
||||
top_level: str
|
||||
name: str
|
||||
branch: str | None
|
||||
head: str | None
|
||||
index_tree: str
|
||||
base: str | None
|
||||
base_short: str | None
|
||||
warnings: tuple[str, ...]
|
||||
notes: tuple[Note, ...]
|
||||
sections: tuple[Section, ...]
|
||||
commits: tuple[Commit, ...]
|
||||
|
||||
@property
|
||||
def commit_files(self) -> int:
|
||||
return sum(len(commit.files) for commit in self.commits)
|
||||
|
||||
@property
|
||||
def commit_added(self) -> int:
|
||||
return sum(commit.added for commit in self.commits)
|
||||
|
||||
@property
|
||||
def commit_removed(self) -> int:
|
||||
return sum(commit.removed for commit in self.commits)
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
||||
|
||||
from .collect import collect_review
|
||||
from .model import Review, ReviewError
|
||||
|
||||
TEMPLATE = "review.html.j2"
|
||||
INVISIBLE = {
|
||||
code: f"<U+{code:04X}>"
|
||||
for code in (
|
||||
*range(0x200B, 0x2010),
|
||||
*range(0x202A, 0x202F),
|
||||
*range(0x2066, 0x206A),
|
||||
0x061C,
|
||||
0xFEFF,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _csp_hash(text: str) -> str:
|
||||
digest = hashlib.sha256(str(text).encode("utf-8")).digest()
|
||||
return "sha256-" + base64.b64encode(digest).decode("ascii")
|
||||
|
||||
|
||||
def _visible(value: object) -> object:
|
||||
# not isinstance: Markup.translate() would splice the marks in unescaped,
|
||||
# and macro output is Markup whose text was already marked when built
|
||||
return value.translate(INVISIBLE) if type(value) is str else value
|
||||
|
||||
|
||||
def render_review(review: Review) -> str:
|
||||
environment = Environment(
|
||||
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__))),
|
||||
autoescape=True,
|
||||
undefined=StrictUndefined,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
keep_trailing_newline=True,
|
||||
finalize=_visible,
|
||||
)
|
||||
environment.filters["csp_hash"] = _csp_hash
|
||||
return environment.get_template(TEMPLATE).render(review=review)
|
||||
|
||||
|
||||
def write_review(
|
||||
cwd: str, out: str, max_file_lines: int, base: str | None, notes: list[str]
|
||||
) -> str:
|
||||
"""
|
||||
Args:
|
||||
cwd: Directory inside the git working copy to review.
|
||||
out: Output HTML path, relative to ``cwd`` unless absolute; written as
|
||||
a new mode 0600 file renamed onto it, so a symbolic link there is
|
||||
replaced, never followed.
|
||||
max_file_lines: Diff lines shown per file before the rest is summarised.
|
||||
base: Branch whose ``REF..HEAD`` commits are listed, or None.
|
||||
notes: Text files shown verbatim at the top of the page.
|
||||
|
||||
Returns:
|
||||
The absolute path of the written page.
|
||||
"""
|
||||
html = render_review(
|
||||
collect_review(cwd=cwd, max_file_lines=max_file_lines, base=base, notes=notes)
|
||||
)
|
||||
path = os.path.abspath(os.path.join(cwd, out))
|
||||
directory = os.path.dirname(path)
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(prefix=".review-", suffix=".tmp", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(html)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp, path)
|
||||
except OSError:
|
||||
os.unlink(temp)
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ReviewError(f"cannot write {path!r}: {exc.strerror}") from None
|
||||
return path
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, SCAN_FLAGS, SCAN_TIMEOUT, _scan_env, run
|
||||
|
||||
from .model import ReviewError
|
||||
|
||||
BRANCH_NAMESPACES = ("refs/heads/", "refs/remotes/")
|
||||
|
||||
|
||||
def git_text(args: list[str], cwd: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *SCAN_FLAGS, "-c", "core.quotepath=false", *args],
|
||||
cwd=cwd,
|
||||
env=_scan_env(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=SCAN_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ReviewError(f"git {args[0]} timed out") from None
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", "replace").strip().splitlines()
|
||||
reason = stderr[0] if stderr else f"exit code {result.returncode}"
|
||||
raise ReviewError(f"git {args[0]} failed: {reason}")
|
||||
return result.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def git_optional(args: list[str], cwd: str) -> str | None:
|
||||
try:
|
||||
return run(args, cwd=cwd, scan=True) or None
|
||||
except GitBaseError:
|
||||
return None
|
||||
|
||||
|
||||
def top_level(cwd: str) -> str:
|
||||
top = git_optional(["rev-parse", "--show-toplevel"], cwd)
|
||||
if top is None:
|
||||
raise ReviewError(f"not inside a git working copy: {cwd!r}")
|
||||
return top
|
||||
|
||||
|
||||
def base_ref(base: str, top: str) -> tuple[str, str]:
|
||||
names = (
|
||||
[base]
|
||||
if base.startswith(BRANCH_NAMESPACES)
|
||||
else [namespace + base for namespace in BRANCH_NAMESPACES]
|
||||
)
|
||||
refs: dict[str, str] = {}
|
||||
for name in names:
|
||||
sha = git_optional(
|
||||
["show-ref", "--verify", "--hash", END_OF_OPTIONS, name], top
|
||||
)
|
||||
if sha is not None:
|
||||
refs[name] = sha
|
||||
if len(refs) > 1:
|
||||
raise ReviewError(f"ambiguous base ref: {base!r} names {' and '.join(refs)}")
|
||||
if not refs:
|
||||
raise ReviewError(
|
||||
f"unknown base ref: {base!r}, expected a branch under "
|
||||
f"{' or '.join(BRANCH_NAMESPACES)}"
|
||||
)
|
||||
[(name, sha)] = refs.items()
|
||||
commit = git_optional(
|
||||
["rev-parse", "--verify", "--quiet", END_OF_OPTIONS, f"{sha}^{{commit}}"], top
|
||||
)
|
||||
if commit is None:
|
||||
raise ReviewError(f"base ref {name!r} does not point to a commit")
|
||||
return name, commit
|
||||
@@ -0,0 +1,353 @@
|
||||
{% set status_letters = {"added": "A", "deleted": "D", "modified": "M", "renamed": "R", "copied": "C", "untracked": "U"} %}
|
||||
{% set signs = {"add": "+", "del": "-", "ctx": "", "meta": ""} %}
|
||||
{% set icon_paths = {
|
||||
"arrows-expand": "M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8M7.646.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 1.707V5.5a.5.5 0 0 1-1 0V1.707L6.354 2.854a.5.5 0 1 1-.708-.708zM8 10a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 0 1 .708-.708L7.5 14.293V10.5A.5.5 0 0 1 8 10",
|
||||
"arrows-collapse": "M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8m7-8a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 1 1 .708-.708L7.5 4.293V.5A.5.5 0 0 1 8 0m-.5 11.707-1.146 1.147a.5.5 0 0 1-.708-.708l2-2a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 11.707V15.5a.5.5 0 0 1-1 0z",
|
||||
} %}
|
||||
{% set style %}
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f8fa;
|
||||
--panel: #ffffff;
|
||||
--subtle: #f6f8fa;
|
||||
--border: #d1d9e0;
|
||||
--border-muted: #e1e6eb;
|
||||
--text: #1f2328;
|
||||
--muted: #59636e;
|
||||
--accent: #0969da;
|
||||
--accent-bg: #ddf4ff;
|
||||
--add-fg: #1a7f37;
|
||||
--add-bg: #dafbe1;
|
||||
--add-line: #e6ffec;
|
||||
--add-num: #ccffd8;
|
||||
--del-fg: #cf222e;
|
||||
--del-bg: #ffebe9;
|
||||
--del-line: #ffebe9;
|
||||
--del-num: #ffd7d5;
|
||||
--hunk-bg: #ddf4ff;
|
||||
--hunk-fg: #59636e;
|
||||
--warn-fg: #9a6700;
|
||||
--warn-bg: #fff8c5;
|
||||
--shadow: 0 1px 2px rgba(31, 35, 40, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #151b23;
|
||||
--subtle: #1b222c;
|
||||
--border: #3d444d;
|
||||
--border-muted: #2a313c;
|
||||
--text: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--accent: #4493f8;
|
||||
--accent-bg: rgba(56, 139, 253, 0.15);
|
||||
--add-fg: #3fb950;
|
||||
--add-bg: rgba(46, 160, 67, 0.2);
|
||||
--add-line: rgba(46, 160, 67, 0.15);
|
||||
--add-num: rgba(63, 185, 80, 0.3);
|
||||
--del-fg: #f85149;
|
||||
--del-bg: rgba(248, 81, 73, 0.2);
|
||||
--del-line: rgba(248, 81, 73, 0.1);
|
||||
--del-num: rgba(248, 81, 73, 0.3);
|
||||
--hunk-bg: rgba(56, 139, 253, 0.1);
|
||||
--hunk-fg: #9198a1;
|
||||
--warn-fg: #d29922;
|
||||
--warn-bg: rgba(187, 128, 9, 0.15);
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html { scroll-padding-top: 64px; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
pre, code { font-family: var(--mono); }
|
||||
.muted { color: var(--muted); font-weight: 400; }
|
||||
.masthead { background: var(--panel); border-bottom: 1px solid var(--border); }
|
||||
.masthead-inner, .toolbar-inner, main { max-width: 1280px; margin: 0 auto; padding-left: 32px; padding-right: 32px; }
|
||||
.masthead-inner { padding-top: 24px; padding-bottom: 20px; }
|
||||
.eyebrow { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; font-size: 22px; font-weight: 600; }
|
||||
.pill { padding: 2px 10px; border-radius: 999px; background: var(--accent-bg); color: var(--accent); font: 500 12px/20px var(--mono); }
|
||||
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 20px; margin: 16px 0 0; font-size: 13px; }
|
||||
.facts dt { color: var(--muted); }
|
||||
.facts dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
|
||||
.warnings, .skipped { border: 1px solid var(--warn-fg); border-radius: 8px; background: var(--warn-bg); color: var(--warn-fg); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.warnings { margin: 16px 0 0; padding: 10px 14px 10px 34px; }
|
||||
.skipped { margin: 0; padding: 14px 16px; border-style: dashed; }
|
||||
.totals { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||
.total { display: flex; flex-direction: column; gap: 2px; min-width: 160px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--subtle); color: var(--text); }
|
||||
.total:hover { border-color: var(--accent); text-decoration: none; }
|
||||
.total .label { color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.total .files { font-size: 15px; font-weight: 600; }
|
||||
.stat { display: inline-flex; gap: 6px; font: 600 12px var(--mono); white-space: nowrap; }
|
||||
.stat .add { color: var(--add-fg); }
|
||||
.stat .del { color: var(--del-fg); }
|
||||
.toolbar { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--border); background: var(--panel); background: color-mix(in srgb, var(--panel) 86%, transparent); backdrop-filter: saturate(180%) blur(10px); box-shadow: var(--shadow); }
|
||||
.toolbar-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-top: 8px; padding-bottom: 8px; }
|
||||
.jumps { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
.jumps a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px; color: var(--text); font-weight: 500; }
|
||||
.jumps a:hover { background: var(--accent-bg); text-decoration: none; }
|
||||
.count { min-width: 20px; padding: 0 6px; border: 1px solid var(--border-muted); border-radius: 999px; background: var(--subtle); color: var(--muted); font-size: 12px; line-height: 18px; text-align: center; }
|
||||
.spacer { flex: 1; }
|
||||
input[type="search"] { width: 260px; max-width: 100%; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font: 13px var(--mono); }
|
||||
input[type="search"]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--subtle); color: var(--text); font: 500 13px var(--sans); cursor: pointer; }
|
||||
button:hover { border-color: var(--muted); }
|
||||
button.icon { display: inline-flex; align-items: center; justify-content: center; padding: 6px 8px; }
|
||||
main { padding-top: 24px; padding-bottom: 80px; }
|
||||
.group { margin-bottom: 36px; }
|
||||
h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; margin: 0 0 12px; font-size: 17px; font-weight: 600; }
|
||||
.note { margin: 0 0 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); overflow: hidden; }
|
||||
.note figcaption { padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--subtle); font: 600 13px var(--mono); }
|
||||
.note pre, .commit-body { margin: 0; padding: 12px 14px; font-size: 13px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
details.file, details.commit { margin-bottom: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); overflow: hidden; }
|
||||
summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: ""; flex: none; width: 6px; height: 6px; margin: 0 4px 0 2px; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s ease; }
|
||||
details[open] > summary::before { transform: rotate(45deg); }
|
||||
details.file > summary { background: var(--subtle); font: 13px var(--mono); }
|
||||
details.file[open] > summary, details.commit[open] > summary { border-bottom: 1px solid var(--border); }
|
||||
summary:hover { background: var(--accent-bg); }
|
||||
.badge { flex: none; width: 20px; height: 20px; border-radius: 5px; font: 700 11px/20px var(--sans); text-align: center; }
|
||||
.badge.added, .badge.untracked { background: var(--add-bg); color: var(--add-fg); }
|
||||
.badge.deleted { background: var(--del-bg); color: var(--del-fg); }
|
||||
.badge.modified { background: var(--accent-bg); color: var(--accent); }
|
||||
.badge.renamed, .badge.copied { background: var(--warn-bg); color: var(--warn-fg); }
|
||||
.path { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.path .old, .path .arrow { color: var(--muted); font-weight: 400; }
|
||||
.tag { flex: none; padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font: 500 11px/18px var(--sans); }
|
||||
.open { flex: none; font: 500 12px var(--sans); }
|
||||
.placeholder, .empty { margin: 0; padding: 14px 16px; color: var(--muted); font-style: italic; }
|
||||
.empty { border: 1px dashed var(--border); border-radius: 8px; }
|
||||
table.diff { width: 100%; border-collapse: collapse; font: 12.5px/20px var(--mono); tab-size: 4; }
|
||||
.diff td { height: 20px; padding: 0; vertical-align: top; }
|
||||
td.num { width: 1%; min-width: 50px; padding: 0 10px; color: var(--muted); text-align: right; white-space: nowrap; user-select: none; }
|
||||
td.num a { color: inherit; }
|
||||
td.num a:hover { color: var(--accent); }
|
||||
td.sign { width: 20px; padding-left: 10px; color: var(--muted); user-select: none; }
|
||||
td.code { padding-right: 16px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
tr.add td { background: var(--add-line); }
|
||||
tr.add td.num { background: var(--add-num); }
|
||||
tr.add td.sign { color: var(--add-fg); }
|
||||
tr.del td { background: var(--del-line); }
|
||||
tr.del td.num { background: var(--del-num); }
|
||||
tr.del td.sign { color: var(--del-fg); }
|
||||
tr.hunk td { padding-top: 4px; padding-bottom: 4px; background: var(--hunk-bg); color: var(--hunk-fg); }
|
||||
tr.meta td.code { color: var(--muted); font-style: italic; }
|
||||
tr.more td { padding: 8px 16px; border-top: 1px dashed var(--border); background: var(--subtle); color: var(--muted); font: italic 12px var(--sans); text-align: center; }
|
||||
details.commit > summary { padding: 10px 14px; }
|
||||
.sha { flex: none; padding: 1px 8px; border: 1px solid var(--border-muted); border-radius: 6px; background: var(--subtle); font-size: 12px; }
|
||||
.subject { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.commit-meta { padding: 10px 14px 0; color: var(--muted); font-size: 13px; }
|
||||
.commit-files { padding: 12px 14px 2px; }
|
||||
@media (max-width: 720px) {
|
||||
.masthead-inner, .toolbar-inner, main { padding-left: 14px; padding-right: 14px; }
|
||||
input[type="search"] { width: 100%; }
|
||||
.spacer { display: none; }
|
||||
}
|
||||
{% endset %}
|
||||
{% set script %}
|
||||
(function () {
|
||||
"use strict";
|
||||
var files = Array.prototype.slice.call(document.querySelectorAll("details.file"));
|
||||
var filter = document.getElementById("filter");
|
||||
var counter = document.getElementById("filter-count");
|
||||
function setOpen(open) {
|
||||
document.querySelectorAll("details").forEach(function (item) {
|
||||
item.open = open;
|
||||
});
|
||||
}
|
||||
function applyFilter() {
|
||||
var query = filter.value.trim().toLowerCase();
|
||||
var shown = 0;
|
||||
files.forEach(function (item) {
|
||||
var match = query === "" || item.dataset.path.toLowerCase().indexOf(query) !== -1;
|
||||
item.hidden = !match;
|
||||
shown += match ? 1 : 0;
|
||||
});
|
||||
counter.textContent = query === "" ? "" : shown + " of " + files.length + " files";
|
||||
}
|
||||
document.getElementById("expand-all").addEventListener("click", function () {
|
||||
setOpen(true);
|
||||
});
|
||||
document.getElementById("collapse-all").addEventListener("click", function () {
|
||||
setOpen(false);
|
||||
});
|
||||
filter.addEventListener("input", applyFilter);
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "/" && document.activeElement !== filter) {
|
||||
event.preventDefault();
|
||||
filter.focus();
|
||||
} else if (event.key === "Escape" && document.activeElement === filter) {
|
||||
filter.value = "";
|
||||
applyFilter();
|
||||
filter.blur();
|
||||
}
|
||||
});
|
||||
applyFilter();
|
||||
})();
|
||||
{% endset %}
|
||||
{% macro stat(added, removed) %}<span class="stat"><span class="add">+{{ added }}</span><span class="del">−{{ removed }}</span></span>{% endmacro %}
|
||||
{% macro icon(name) %}<svg class="bi bi-{{ name }}" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path fill-rule="evenodd" d="{{ icon_paths[name] }}"/></svg>{% endmacro %}
|
||||
{% macro plural(count, word) %}{{ count }} {{ word }}{{ "" if count == 1 else "s" }}{% endmacro %}
|
||||
{% macro file_block(file, open) %}
|
||||
<details class="file" id="{{ file.anchor }}" data-path="{{ file.search }}"{% if open %} open{% endif %}>
|
||||
<summary>
|
||||
<span class="badge {{ file.diff.status }}" title="{{ file.diff.status }}">{{ status_letters[file.diff.status] }}</span>
|
||||
<span class="path">{% if file.diff.moved %}<span class="old">{{ file.diff.old_path }}</span><span class="arrow"> → </span>{% endif %}{{ file.diff.path }}</span>
|
||||
{% if file.diff.mode_change %}
|
||||
<span class="tag">mode {{ file.diff.mode_change }}</span>
|
||||
{% endif %}
|
||||
{% if file.diff.binary %}
|
||||
<span class="tag">binary</span>
|
||||
{% endif %}
|
||||
{{ stat(file.diff.added, file.diff.removed) }}
|
||||
{% if file.link %}
|
||||
<a class="open" href="{{ file.link }}" title="Open in VS Code">Open ↗</a>
|
||||
{% endif %}
|
||||
</summary>
|
||||
{% if file.detail %}
|
||||
<p class="placeholder">{{ file.detail }}</p>
|
||||
{% elif not file.hunks %}
|
||||
<p class="placeholder">No content changes.</p>
|
||||
{% else %}
|
||||
<table class="diff">
|
||||
{% for hunk in file.hunks %}
|
||||
<tbody>
|
||||
<tr class="hunk"><td class="num" colspan="2"></td><td class="code" colspan="2">{{ hunk.header }}</td></tr>
|
||||
{% for line in hunk.lines %}
|
||||
<tr class="{{ line.kind }}"><td class="num">{{ "" if line.old is none else line.old }}</td><td class="num">{% if line.new is not none and file.link_base %}<a href="{{ file.link_base }}:{{ line.new }}">{{ line.new }}</a>{% elif line.new is not none %}{{ line.new }}{% endif %}</td><td class="sign">{{ signs[line.kind] }}</td><td class="code">{{ line.text }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
{% if file.hidden %}
|
||||
<tbody><tr class="more"><td colspan="4">… {{ plural(file.hidden, "more line") }} not shown</td></tr></tbody>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endmacro %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src '{{ style|csp_hash }}'; script-src '{{ script|csp_hash }}'; base-uri 'none'; form-action 'none'">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Review: {{ review.name }}{% if review.branch %} ({{ review.branch }}){% endif %}</title>
|
||||
<style>{{ style }}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<div class="masthead-inner">
|
||||
<p class="eyebrow">Working copy review</p>
|
||||
<h1>{{ review.name }} <span class="pill">{{ review.branch or "detached HEAD" }}</span></h1>
|
||||
<dl class="facts">
|
||||
<dt>Top level</dt><dd>{{ review.top_level }}</dd>
|
||||
<dt>Branch</dt><dd>{{ review.branch or "(detached HEAD)" }}</dd>
|
||||
<dt>HEAD</dt><dd>{{ review.head or "(no commits yet)" }}</dd>
|
||||
<dt>Index tree</dt><dd>{{ review.index_tree }}</dd>
|
||||
{% if review.base is not none %}
|
||||
<dt>Base</dt><dd>{{ review.base }} @ {{ review.base_short }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if review.warnings %}
|
||||
<ul class="warnings">
|
||||
{% for warning in review.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="totals">
|
||||
{% for section in review.sections %}
|
||||
<a class="total" href="#{{ section.key }}"><span class="label">{{ section.title }}</span><span class="files">{{ plural(section.files|length, "file") }}</span>{{ stat(section.added, section.removed) }}</a>
|
||||
{% endfor %}
|
||||
{% if review.base is not none %}
|
||||
<a class="total" href="#commits"><span class="label">Commits</span><span class="files">{{ plural(review.commits|length, "commit") }} · {{ plural(review.commit_files, "file") }}</span>{{ stat(review.commit_added, review.commit_removed) }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="toolbar">
|
||||
<div class="toolbar-inner">
|
||||
<div class="jumps">
|
||||
{% if review.notes %}
|
||||
<a href="#notes">Notes <span class="count">{{ review.notes|length }}</span></a>
|
||||
{% endif %}
|
||||
{% for section in review.sections %}
|
||||
<a href="#{{ section.key }}">{{ section.title }} <span class="count">{{ section.files|length }}</span></a>
|
||||
{% endfor %}
|
||||
{% if review.base is not none %}
|
||||
<a href="#commits">Commits <span class="count">{{ review.commits|length }}</span></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<input id="filter" type="search" placeholder="Filter files by path ( / )" autocomplete="off" spellcheck="false" aria-label="Filter files by path">
|
||||
<span id="filter-count" class="muted"></span>
|
||||
<button type="button" id="expand-all" class="icon" title="Expand all" aria-label="Expand all">{{ icon("arrows-expand") }}</button>
|
||||
<button type="button" id="collapse-all" class="icon" title="Collapse all" aria-label="Collapse all">{{ icon("arrows-collapse") }}</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
{% if review.notes %}
|
||||
<section class="group" id="notes">
|
||||
<h2>Notes</h2>
|
||||
{% for note in review.notes %}
|
||||
<figure class="note">
|
||||
<figcaption>{{ note.title }}</figcaption>
|
||||
<pre>
|
||||
{{ note.text }}</pre>
|
||||
</figure>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% for section in review.sections %}
|
||||
<section class="group" id="{{ section.key }}" data-files="{{ section.files|length }}" data-added="{{ section.added }}" data-removed="{{ section.removed }}">
|
||||
<h2>{{ section.title }} <span class="muted">{{ plural(section.files|length, "file") }}</span>{{ stat(section.added, section.removed) }}</h2>
|
||||
{% if section.skipped %}
|
||||
<p class="skipped">{{ section.skipped }}</p>
|
||||
{% else %}
|
||||
{% for file in section.files %}
|
||||
{{ file_block(file, true) }}
|
||||
{% else %}
|
||||
<p class="empty">No changes.</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% if review.base is not none %}
|
||||
<section class="group" id="commits" data-commits="{{ review.commits|length }}" data-files="{{ review.commit_files }}" data-added="{{ review.commit_added }}" data-removed="{{ review.commit_removed }}">
|
||||
<h2>Commits <span class="muted">{{ review.base }}..HEAD · {{ plural(review.commits|length, "commit") }}</span>{{ stat(review.commit_added, review.commit_removed) }}</h2>
|
||||
{% for commit in review.commits %}
|
||||
<details class="commit" id="{{ commit.anchor }}" open>
|
||||
<summary>
|
||||
<code class="sha">{{ commit.short }}</code>
|
||||
<span class="subject">{{ commit.subject }}</span>
|
||||
{{ stat(commit.added, commit.removed) }}
|
||||
</summary>
|
||||
<div class="commit-meta">{{ commit.author }}</div>
|
||||
{% if commit.body %}
|
||||
<pre class="commit-body">
|
||||
{{ commit.body }}</pre>
|
||||
{% endif %}
|
||||
<div class="commit-files">
|
||||
{% for file in commit.files %}
|
||||
{{ file_block(file, false) }}
|
||||
{% else %}
|
||||
<p class="placeholder">No file changes.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<p class="empty">No commits between {{ review.base }} and HEAD.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<script>{{ script }}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from .model import FileDiff, FileView, Hunk
|
||||
|
||||
BINARY_DETAIL = "Binary file, contents not shown."
|
||||
|
||||
|
||||
def _first_changed_line(hunks: tuple[Hunk, ...]) -> int:
|
||||
for hunk in hunks:
|
||||
position = hunk.new_start
|
||||
for line in hunk.lines:
|
||||
if line.kind in ("add", "del"):
|
||||
return max(position, 1)
|
||||
if line.new is not None:
|
||||
position = line.new + 1
|
||||
return 1
|
||||
|
||||
|
||||
def _truncate(hunks: tuple[Hunk, ...], limit: int) -> tuple[tuple[Hunk, ...], int]:
|
||||
shown: list[Hunk] = []
|
||||
budget = limit
|
||||
for hunk in hunks:
|
||||
if budget <= 0:
|
||||
break
|
||||
lines = hunk.lines[:budget]
|
||||
shown.append(Hunk(hunk.header, hunk.new_start, lines))
|
||||
budget -= len(lines)
|
||||
total = sum(len(hunk.lines) for hunk in hunks)
|
||||
return tuple(shown), total - (limit - budget)
|
||||
|
||||
|
||||
def file_view(
|
||||
diff: FileDiff, anchor: str, top: str, limit: int, detail: str | None
|
||||
) -> FileView:
|
||||
link_base = None
|
||||
if diff.new_path is not None:
|
||||
link_base = "vscode://file" + quote(os.path.join(top, diff.new_path))
|
||||
hunks, hidden = _truncate(diff.hunks, limit)
|
||||
return FileView(
|
||||
anchor=anchor,
|
||||
diff=diff,
|
||||
detail=detail,
|
||||
link_base=link_base,
|
||||
link=None
|
||||
if link_base is None
|
||||
else f"{link_base}:{_first_changed_line(diff.hunks)}",
|
||||
hunks=hunks,
|
||||
hidden=hidden,
|
||||
)
|
||||
|
||||
|
||||
def file_views(
|
||||
diffs: list[FileDiff], prefix: str, top: str, limit: int
|
||||
) -> tuple[FileView, ...]:
|
||||
return tuple(
|
||||
file_view(
|
||||
diff,
|
||||
f"{prefix}-{index}",
|
||||
top,
|
||||
limit,
|
||||
BINARY_DETAIL if diff.binary else None,
|
||||
)
|
||||
for index, diff in enumerate(diffs, 1)
|
||||
)
|
||||
Whitespace-only changes.
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.actions.branch.nofollow import DIR_FLAGS
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.git.run import run
|
||||
|
||||
FLOW_DIR = "pkgmgr"
|
||||
FLOW_FILE = "flow.yml"
|
||||
RELATIVE = f"{FLOW_DIR}/{FLOW_FILE}"
|
||||
VERSION = 1
|
||||
MAX_BYTES = 64 * 1024
|
||||
FOLDER_MODE = 0o700
|
||||
FILE_MODE = 0o600
|
||||
KEYS = ("version", "branch", "pull", "push")
|
||||
REQUIRED = ("version", "pull", "push")
|
||||
# O_NONBLOCK: opening a planted FIFO for reading would block until a writer comes
|
||||
READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||
WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
|
||||
DETACHED = "a detached HEAD"
|
||||
NONE_LISTED = "(none)"
|
||||
|
||||
|
||||
class FlowError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Flow:
|
||||
pull: list[str] = field(default_factory=list)
|
||||
push: list[str] = field(default_factory=list)
|
||||
branch: str | None = None
|
||||
source: str = "none"
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def describe(exc: BaseException) -> str:
|
||||
return (str(exc).strip().splitlines() or [type(exc).__name__])[0]
|
||||
|
||||
|
||||
def _ref_format(name: str) -> bool:
|
||||
# check-ref-format needs no repository, so / keeps any repository config
|
||||
# out; --branch would expand @{-N} into another name inside one
|
||||
try:
|
||||
return (
|
||||
run(["check-ref-format", "--branch", name], cwd=os.sep, scan=True) == name
|
||||
)
|
||||
except (GitBaseError, OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def check_name(kind: str, name: object) -> str:
|
||||
"""
|
||||
Args:
|
||||
kind: pull, push or branch, for the message.
|
||||
name: an entry from the file or the command line.
|
||||
|
||||
Returns:
|
||||
name, once it is a string git accepts as a branch name that does not
|
||||
start with `-`; a remote-tracking name such as origin/main passes.
|
||||
|
||||
Raises:
|
||||
FlowError: name is anything else.
|
||||
"""
|
||||
if not isinstance(name, str) or name.startswith("-") or not _ref_format(name):
|
||||
raise FlowError(f"{kind} {name!r} is not a valid branch name")
|
||||
return name
|
||||
|
||||
|
||||
def check_names(kind: str, names: object) -> list[str]:
|
||||
"""
|
||||
Args:
|
||||
kind: pull or push, for the message.
|
||||
names: the list from the file or the command line.
|
||||
|
||||
Returns:
|
||||
The names in order, each passed through check_name.
|
||||
|
||||
Raises:
|
||||
FlowError: names is not a list or an entry is invalid.
|
||||
"""
|
||||
if not isinstance(names, list):
|
||||
raise FlowError(f"{kind} must be a list of branch names")
|
||||
return [check_name(kind, name) for name in names]
|
||||
|
||||
|
||||
def _folder(git_dir: str, create: bool) -> int:
|
||||
parent = os.open(git_dir, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
if create:
|
||||
with suppress(FileExistsError):
|
||||
os.mkdir(FLOW_DIR, FOLDER_MODE, dir_fd=parent)
|
||||
return os.open(FLOW_DIR, DIR_FLAGS, dir_fd=parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
|
||||
|
||||
def _read_text(git_dir: str) -> str | None:
|
||||
try:
|
||||
folder = _folder(git_dir, create=False)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
fd = os.open(FLOW_FILE, READ_FLAGS, dir_fd=folder)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
finally:
|
||||
os.close(folder)
|
||||
# os.fdopen refuses a directory but leaves its fd open, so check first
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
os.close(fd)
|
||||
raise FlowError("not a regular file")
|
||||
with os.fdopen(fd, "rb") as fh:
|
||||
data = fh.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise FlowError(f"larger than {MAX_BYTES} bytes")
|
||||
return data.decode("utf-8")
|
||||
|
||||
|
||||
def _parse(text: str) -> tuple[str | None, list[str], list[str]]:
|
||||
data = yaml.safe_load(text)
|
||||
if not isinstance(data, dict):
|
||||
raise FlowError("not a mapping")
|
||||
unknown = [repr(key) for key in data if key not in KEYS]
|
||||
if unknown:
|
||||
raise FlowError(f"unknown keys {', '.join(unknown)}")
|
||||
missing = [key for key in REQUIRED if key not in data]
|
||||
if missing:
|
||||
raise FlowError(f"missing keys {', '.join(missing)}")
|
||||
# bool is an int, and True == 1
|
||||
if type(data["version"]) is not int or data["version"] != VERSION:
|
||||
raise FlowError(f"version must be {VERSION}")
|
||||
branch = data.get("branch")
|
||||
if branch is not None:
|
||||
check_name("branch", branch)
|
||||
return branch, check_names("pull", data["pull"]), check_names("push", data["push"])
|
||||
|
||||
|
||||
def read_flow(git_dir: str) -> Flow:
|
||||
"""
|
||||
Read `<git_dir>/pkgmgr/flow.yml`, which an agent may have written.
|
||||
|
||||
Args:
|
||||
git_dir: the worktree's own git directory.
|
||||
|
||||
Returns:
|
||||
The flow with source file; source none when there is no file; source
|
||||
invalid, empty lists and a warning when the file is a symbolic link,
|
||||
no regular file, larger than MAX_BYTES or malformed. Never raises.
|
||||
"""
|
||||
try:
|
||||
text = _read_text(git_dir)
|
||||
if text is None:
|
||||
return Flow()
|
||||
branch, pull, push = _parse(text)
|
||||
# RecursionError: safe_load recurses once per nesting level
|
||||
except (OSError, ValueError, yaml.YAMLError, RecursionError) as exc:
|
||||
return Flow(source="invalid", warnings=[f"{RELATIVE} ignored: {describe(exc)}"])
|
||||
return Flow(pull=pull, push=push, branch=branch, source="file")
|
||||
|
||||
|
||||
def write_flow(
|
||||
git_dir: str, branch: str | None, pull: list[str], push: list[str]
|
||||
) -> None:
|
||||
"""
|
||||
Replace the flow atomically, never writing through a symbolic link.
|
||||
|
||||
Args:
|
||||
git_dir: the worktree's own git directory.
|
||||
branch: the worktree's branch now; None when detached.
|
||||
pull: refs merged into the branch, in order, already through check_names.
|
||||
push: branches the branch is merged into, in order, likewise checked.
|
||||
|
||||
Raises:
|
||||
OSError: `pkgmgr` is a symbolic link or no directory, or a write failed.
|
||||
"""
|
||||
data = {"version": VERSION, "branch": branch, "pull": pull, "push": push}
|
||||
text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
folder = _folder(git_dir, create=True)
|
||||
tmp = f".{FLOW_FILE}.{secrets.token_hex(8)}"
|
||||
try:
|
||||
fd = os.open(tmp, WRITE_FLAGS, FILE_MODE, dir_fd=folder)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, FLOW_FILE, src_dir_fd=folder, dst_dir_fd=folder)
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(tmp, dir_fd=folder)
|
||||
os.close(folder)
|
||||
|
||||
|
||||
def clear_flow(git_dir: str) -> None:
|
||||
"""
|
||||
Remove the flow file; a symbolic link in its place goes, never its target.
|
||||
|
||||
Args:
|
||||
git_dir: the worktree's own git directory.
|
||||
|
||||
Raises:
|
||||
OSError: `pkgmgr` is a symbolic link or no directory, or the unlink failed.
|
||||
"""
|
||||
try:
|
||||
folder = _folder(git_dir, create=False)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
try:
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(FLOW_FILE, dir_fd=folder)
|
||||
finally:
|
||||
os.close(folder)
|
||||
|
||||
|
||||
def mismatch(flow: Flow, branch: str | None) -> list[str]:
|
||||
"""
|
||||
Args:
|
||||
flow: a flow from read_flow.
|
||||
branch: the worktree's current branch; None when detached.
|
||||
|
||||
Returns:
|
||||
One warning when the file was written on another branch, else none.
|
||||
"""
|
||||
if flow.source != "file" or flow.branch == branch:
|
||||
return []
|
||||
warning = (
|
||||
f"flow was set on {flow.branch or DETACHED}, "
|
||||
f"the worktree is on {branch or DETACHED}"
|
||||
)
|
||||
return [warning]
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.git.run import run
|
||||
|
||||
from .flow import (
|
||||
NONE_LISTED,
|
||||
Flow,
|
||||
FlowError,
|
||||
check_names,
|
||||
clear_flow,
|
||||
describe,
|
||||
mismatch,
|
||||
read_flow,
|
||||
write_flow,
|
||||
)
|
||||
|
||||
Located = tuple[str, str, str | None]
|
||||
|
||||
|
||||
def locate(path: str) -> Located:
|
||||
"""
|
||||
Args:
|
||||
path: any directory inside the worktree.
|
||||
|
||||
Returns:
|
||||
The worktree root, its own git directory (`.git` of the main
|
||||
worktree, `<common>/worktrees/<id>` of a linked one) and its branch,
|
||||
None when detached.
|
||||
|
||||
Raises:
|
||||
GitBaseError: path is not inside a worktree, e.g. a bare repository.
|
||||
OSError: path is no directory.
|
||||
ValueError: a path git printed spans lines.
|
||||
"""
|
||||
out = run(
|
||||
["rev-parse", "--absolute-git-dir", "--show-toplevel"], cwd=path, scan=True
|
||||
)
|
||||
git_dir, top = out.split("\n")
|
||||
try:
|
||||
ref = run(["symbolic-ref", "--quiet", "HEAD"], cwd=top, scan=True)
|
||||
except GitBaseError:
|
||||
ref = ""
|
||||
branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else None
|
||||
return top, git_dir, branch
|
||||
|
||||
|
||||
def _located(path: str) -> Located | None:
|
||||
try:
|
||||
return locate(path)
|
||||
except (GitBaseError, OSError, ValueError) as exc:
|
||||
print(
|
||||
f"Refusing: {path} is not a git worktree: {describe(exc)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _report(located: Located, flow: Flow, as_json: bool) -> None:
|
||||
top, git_dir, branch = located
|
||||
result = {
|
||||
"worktree": top,
|
||||
"git_dir": git_dir,
|
||||
"branch": branch,
|
||||
"flow_branch": flow.branch,
|
||||
"pull": flow.pull,
|
||||
"push": flow.push,
|
||||
"source": flow.source,
|
||||
"warnings": flow.warnings + mismatch(flow, branch),
|
||||
}
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
for warning in result["warnings"]:
|
||||
print(f"WARN {warning}")
|
||||
for key in ("pull", "push"):
|
||||
print(f"{key}: {', '.join(result[key]) or NONE_LISTED}")
|
||||
|
||||
|
||||
def worktree_flow_show(path: str, *, as_json: bool) -> int:
|
||||
"""
|
||||
Print the flow of the worktree at path.
|
||||
|
||||
Args:
|
||||
path: any directory inside the worktree.
|
||||
as_json: print the result as JSON instead of text.
|
||||
|
||||
Returns:
|
||||
0, also without a flow or with an invalid file; 2 when path is not a
|
||||
git worktree.
|
||||
"""
|
||||
located = _located(path)
|
||||
if located is None:
|
||||
return 2
|
||||
_report(located, read_flow(located[1]), as_json)
|
||||
return 0
|
||||
|
||||
|
||||
def worktree_flow_set(
|
||||
path: str, *, pull: list[str], push: list[str], as_json: bool
|
||||
) -> int:
|
||||
"""
|
||||
Replace both lists of the worktree's flow and print the result.
|
||||
|
||||
Args:
|
||||
path: any directory inside the worktree.
|
||||
pull: refs merged into the branch before a gate, deploy or merge, in
|
||||
order; may be empty.
|
||||
push: branches the branch is merged into when finished, in order;
|
||||
may be empty.
|
||||
as_json: print the result as JSON instead of text.
|
||||
|
||||
Returns:
|
||||
0 when written, 1 when the write failed, 2 on an invalid name or when
|
||||
path is not a git worktree; nothing is written unless 0.
|
||||
"""
|
||||
try:
|
||||
pull, push = check_names("pull", pull), check_names("push", push)
|
||||
except FlowError as exc:
|
||||
print(f"Refusing: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
located = _located(path)
|
||||
if located is None:
|
||||
return 2
|
||||
try:
|
||||
write_flow(located[1], located[2], pull, push)
|
||||
except OSError as exc:
|
||||
print(f"Cannot write the flow of {located[0]}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_report(located, read_flow(located[1]), as_json)
|
||||
return 0
|
||||
|
||||
|
||||
def worktree_flow_clear(path: str, *, as_json: bool) -> int:
|
||||
"""
|
||||
Remove the worktree's flow and print the empty result.
|
||||
|
||||
Args:
|
||||
path: any directory inside the worktree.
|
||||
as_json: print the result as JSON instead of text.
|
||||
|
||||
Returns:
|
||||
0 when no flow is left, 1 when the removal failed, 2 when path is not
|
||||
a git worktree.
|
||||
"""
|
||||
located = _located(path)
|
||||
if located is None:
|
||||
return 2
|
||||
try:
|
||||
clear_flow(located[1])
|
||||
except OSError as exc:
|
||||
print(f"Cannot clear the flow of {located[0]}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_report(located, read_flow(located[1]), as_json)
|
||||
return 0
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .trust import root_resolved, trusted_path
|
||||
|
||||
INIT_HOOK = "worktree-init"
|
||||
FINI_HOOK = "worktree-fini"
|
||||
MAKEFILES = ("GNUmakefile", "makefile", "Makefile")
|
||||
MAKEFILE_LIMIT = 1 << 20
|
||||
HOOK_TIMEOUT = 1800
|
||||
HOOK_GRACE = 5
|
||||
PLACEHOLDER = re.compile(r"\{(path|slot)\}")
|
||||
|
||||
|
||||
def defined_in(path: str, target: str) -> str | None:
|
||||
"""
|
||||
Name the makefile in path that defines target, read as text and never run:
|
||||
make expands $(shell) and include lines even under -n.
|
||||
|
||||
Args:
|
||||
path: directory that may hold a makefile.
|
||||
target: make target name.
|
||||
|
||||
Returns:
|
||||
The first makefile, in make's lookup order, with a `<target>:` rule;
|
||||
None when none has one.
|
||||
"""
|
||||
rule = re.compile(rf"^{re.escape(target)}\s*:", re.MULTILINE)
|
||||
for name in MAKEFILES:
|
||||
file = os.path.join(path, name)
|
||||
if os.path.islink(file) or not os.path.isfile(file):
|
||||
continue
|
||||
with open(file, encoding="utf-8", errors="replace") as fh:
|
||||
if rule.search(fh.read(MAKEFILE_LIMIT)):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def hook_command(name: str, argv: list[str], path: str, slot: int | None) -> list[str]:
|
||||
"""
|
||||
Fill a pinned hook command in one pass and resolve its program.
|
||||
|
||||
Args:
|
||||
name: the hook, for error messages.
|
||||
argv: the command pinned in the `worktree_hooks` config.
|
||||
path: the worktree, replacing `{path}`.
|
||||
slot: the worktree's slot, replacing `{slot}`; None leaves it as is.
|
||||
|
||||
Returns:
|
||||
The argv to run; its program is an absolute path, for a bare name the
|
||||
resolved file that was checked, so a multi-call binary sees that
|
||||
file's name; pin the link's absolute path to keep the link's name.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the program is neither absolute nor a bare name found,
|
||||
as a root-controlled file, on a root-owned PATH entry no group or
|
||||
other user can write; anything relative resolves in the worktree.
|
||||
"""
|
||||
|
||||
def fill(match: re.Match[str]) -> str:
|
||||
if match.group(1) == "path":
|
||||
return path
|
||||
return match.group(0) if slot is None else str(slot)
|
||||
|
||||
filled = [PLACEHOLDER.sub(fill, arg) for arg in argv]
|
||||
program = filled[0] if filled else ""
|
||||
if not os.path.isabs(program):
|
||||
found = (
|
||||
shutil.which(program, path=trusted_path())
|
||||
if program and "/" not in program
|
||||
else None
|
||||
)
|
||||
resolved = root_resolved(found) if found else None
|
||||
if resolved is None:
|
||||
raise RuntimeError(
|
||||
f"{name} pin {program!r} is neither an absolute path nor a "
|
||||
"root-owned command on a root-owned PATH entry"
|
||||
)
|
||||
program = resolved
|
||||
return [program, *filled[1:]]
|
||||
|
||||
|
||||
def run_hook(
|
||||
name: str, command: list[str], path: str, *, check: bool, **kwargs
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a resolved hook command in the worktree.
|
||||
|
||||
Args:
|
||||
name: the hook, for error messages.
|
||||
command: the argv from hook_command.
|
||||
path: the worktree to run in.
|
||||
check: raise CalledProcessError on a non-zero exit.
|
||||
kwargs: further subprocess.Popen arguments, such as output handling.
|
||||
|
||||
Returns:
|
||||
The completed process; stdin was closed, PATH held only its
|
||||
root-controlled entries and HOOK_TIMEOUT applied to its whole process
|
||||
group.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the hook ran longer than HOOK_TIMEOUT, or no PATH entry
|
||||
is root-controlled.
|
||||
"""
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
cwd=path,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env={**os.environ, "PATH": trusted_path()},
|
||||
start_new_session=True,
|
||||
**kwargs,
|
||||
) as process:
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=HOOK_TIMEOUT)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
_stop_group(process)
|
||||
raise RuntimeError(f"{name} timed out after {HOOK_TIMEOUT} s") from exc
|
||||
if check and process.returncode:
|
||||
raise subprocess.CalledProcessError(
|
||||
process.returncode, command, output=stdout, stderr=stderr
|
||||
)
|
||||
return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
|
||||
|
||||
|
||||
def _stop_group(process: subprocess.Popen) -> None:
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(process.pid, sig)
|
||||
except OSError:
|
||||
return
|
||||
deadline = time.monotonic() + HOOK_GRACE
|
||||
while time.monotonic() < deadline:
|
||||
process.poll()
|
||||
try:
|
||||
os.killpg(process.pid, 0)
|
||||
except OSError:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
from pkgmgr.actions.branch.gc import Item, human_size, prepare
|
||||
|
||||
DAY = 86400
|
||||
ORDER = ("merged-clean", "merged-dirty", "stale-open", "stale", "active")
|
||||
PROPOSALS = {
|
||||
"merged-clean": "pkgmgr gc",
|
||||
"merged-dirty": "ask",
|
||||
"stale-open": "ask",
|
||||
"stale": "git worktree prune",
|
||||
"active": "keep",
|
||||
}
|
||||
GUARDS = ("process:", "lock:")
|
||||
HEADER = (
|
||||
"Class",
|
||||
"Branch",
|
||||
"Worktree",
|
||||
"Dirty",
|
||||
"Unpushed",
|
||||
"Last activity",
|
||||
"Proposal",
|
||||
)
|
||||
|
||||
|
||||
def classify(item: Item, now: float, limit: float) -> str:
|
||||
"""
|
||||
Sort one scanned item into an orphan class.
|
||||
|
||||
Args:
|
||||
item: item from a scan with activity recorded.
|
||||
now: current epoch seconds.
|
||||
limit: seconds of inactivity after which unfinished work is stale.
|
||||
|
||||
Returns:
|
||||
One of ORDER.
|
||||
"""
|
||||
if item.state == "stale":
|
||||
return "stale"
|
||||
if (
|
||||
item.state == "protected"
|
||||
or (item.branch or "").startswith("archive/")
|
||||
or item.skip == "checked out in main worktree"
|
||||
):
|
||||
return "active"
|
||||
if item.merged:
|
||||
return "merged-dirty" if item.state == "dirty" else "merged-clean"
|
||||
pending = item.state == "dirty" or bool(item.unpushed) or not item.remotes
|
||||
idle = item.last_activity is not None and now - item.last_activity > limit
|
||||
guarded = bool(item.skip) and item.skip.startswith(GUARDS)
|
||||
return "stale-open" if pending and idle and not guarded else "active"
|
||||
|
||||
|
||||
def proposal(item: Item, cls: str) -> str:
|
||||
if cls == "active":
|
||||
return "keep"
|
||||
if item.skip and item.skip.startswith(GUARDS):
|
||||
return f"keep ({item.skip})"
|
||||
return PROPOSALS[cls]
|
||||
|
||||
|
||||
def _cells(row: dict, now: float, sizes: bool) -> list[str]:
|
||||
activity = row["last_activity"]
|
||||
when = (
|
||||
f"{time.strftime('%Y-%m-%d', time.localtime(activity))} "
|
||||
f"({int((now - activity) // DAY)} d)"
|
||||
if activity
|
||||
else "-"
|
||||
)
|
||||
values = [
|
||||
row["class"],
|
||||
row["branch"] or "(detached)",
|
||||
row["worktree"] or "-",
|
||||
*(
|
||||
[human_size(row["size_bytes"]) if row["size_bytes"] else "-"]
|
||||
if sizes
|
||||
else []
|
||||
),
|
||||
str(row["dirty"]),
|
||||
"-" if row["unpushed"] is None else str(row["unpushed"]),
|
||||
when,
|
||||
row["proposal"],
|
||||
]
|
||||
return [value.replace("|", "\\|") for value in values]
|
||||
|
||||
|
||||
def _table(rows: list[dict], now: float, sizes: bool) -> list[str]:
|
||||
header = [*HEADER[:3], *(["Size"] if sizes else []), *HEADER[3:]]
|
||||
lines = [
|
||||
"| " + " | ".join(header) + " |",
|
||||
"| " + " | ".join("---" for _ in header) + " |",
|
||||
]
|
||||
return lines + ["| " + " | ".join(_cells(r, now, sizes)) + " |" for r in rows]
|
||||
|
||||
|
||||
def worktree_orphans(
|
||||
repo_roots: list[str],
|
||||
*,
|
||||
stale_days: int,
|
||||
top: int,
|
||||
as_json: bool,
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
) -> int:
|
||||
"""
|
||||
List merged and abandoned branches and worktrees with a proposal each.
|
||||
|
||||
Read-only apart from the fetch the scan does. Merged covers ancestry, a
|
||||
merged pull request, an integration line and patch equivalence.
|
||||
|
||||
Args:
|
||||
repo_roots: main worktree roots of the repositories to scan.
|
||||
stale_days: unfinished work idle for longer is stale-open.
|
||||
top: rows per class in the text table; 0 shows the counts only.
|
||||
as_json: print every row, active ones included, as JSON.
|
||||
include: only items whose branch or worktree path matches one of these.
|
||||
exclude: drop items whose branch or worktree path matches one of these.
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage and order by it.
|
||||
|
||||
Returns:
|
||||
0 on success, 2 when refused.
|
||||
"""
|
||||
options = {
|
||||
"merged_into": merged_into,
|
||||
"sizes": sizes,
|
||||
"min_age": None,
|
||||
"scope": "all",
|
||||
"activity": True,
|
||||
"hooks": {},
|
||||
}
|
||||
prepared = prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, host_ns, chosen = prepared
|
||||
now = time.time()
|
||||
limit = stale_days * DAY
|
||||
|
||||
report = []
|
||||
for repo in repos:
|
||||
rows = []
|
||||
for item in repo.items:
|
||||
if not chosen(item):
|
||||
continue
|
||||
cls = classify(item, now, limit)
|
||||
rows.append({**asdict(item), "class": cls, "proposal": proposal(item, cls)})
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
ORDER.index(r["class"]),
|
||||
-(r["size_bytes"] or 0) if sizes else 0,
|
||||
r["last_activity"] or 0,
|
||||
)
|
||||
)
|
||||
report.append({"repo": repo.repo, "warnings": repo.warnings, "rows": rows})
|
||||
|
||||
if as_json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"host_pid_namespace": host_ns,
|
||||
"stale_days": stale_days,
|
||||
"repos": report,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
for entry in report:
|
||||
print(f"== {entry['repo']} (stale after {stale_days} days)")
|
||||
for warning in entry["warnings"]:
|
||||
print(f"WARN {warning}")
|
||||
for cls in ORDER:
|
||||
count = sum(r["class"] == cls for r in entry["rows"])
|
||||
print(f"- {cls}: {count} ({PROPOSALS[cls]})")
|
||||
shown = [
|
||||
row
|
||||
for cls in ORDER[:-1]
|
||||
for row in [r for r in entry["rows"] if r["class"] == cls][:top]
|
||||
]
|
||||
if shown:
|
||||
print()
|
||||
print("\n".join(_table(shown, now, sizes)))
|
||||
if not host_ns:
|
||||
print(
|
||||
"WARN busy and lock checks unavailable: not in the host PID namespace"
|
||||
)
|
||||
return 0
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
SLOT_FILE = "pkgmgr-slot"
|
||||
|
||||
|
||||
def used_slots(common_dir: str) -> set[int]:
|
||||
base = os.path.join(common_dir, "worktrees")
|
||||
slots: set[int] = set()
|
||||
for entry in os.listdir(base) if os.path.isdir(base) else []:
|
||||
try:
|
||||
with open(os.path.join(base, entry, SLOT_FILE), encoding="utf-8") as fh:
|
||||
slots.add(int(fh.read().strip()))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return slots
|
||||
|
||||
|
||||
def next_slot(used: set[int]) -> int:
|
||||
slot = 1
|
||||
while slot in used:
|
||||
slot += 1
|
||||
return slot
|
||||
|
||||
|
||||
def new_worktree_meta(path: str) -> str:
|
||||
"""
|
||||
Args:
|
||||
path: a worktree this process just added, whose `.git` file names
|
||||
its metadata.
|
||||
|
||||
Returns:
|
||||
The worktree's own git directory, `<common>/worktrees/<id>`.
|
||||
"""
|
||||
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
|
||||
return os.path.normpath(
|
||||
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
|
||||
)
|
||||
|
||||
|
||||
def write_slot(path: str, slot: int) -> None:
|
||||
"""
|
||||
Record a slot in the metadata of a worktree this process just added.
|
||||
|
||||
Args:
|
||||
path: the new worktree, whose `.git` file names its metadata.
|
||||
slot: the slot to record.
|
||||
"""
|
||||
with open(
|
||||
os.path.join(new_worktree_meta(path), SLOT_FILE), "w", encoding="utf-8"
|
||||
) as fh:
|
||||
fh.write(f"{slot}\n")
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
ROOT_UID = 0
|
||||
UID_MAP = "/proc/self/uid_map"
|
||||
INITIAL_MAP = [[0, 0, 4294967295]]
|
||||
OVERFLOW_UID = "/proc/sys/kernel/overflowuid"
|
||||
SHARED_WRITE = stat.S_IWGRP | stat.S_IWOTH
|
||||
|
||||
|
||||
def root_uids() -> frozenset[int]:
|
||||
"""
|
||||
Returns:
|
||||
The owner uids that stand for root. In the initial user namespace,
|
||||
whose uid_map is the identity over every uid, that is 0. In any other
|
||||
namespace a mapped uid 0 may be any user, so only the overflow uid
|
||||
counts: the real root's files show up under it there, as do those of
|
||||
every other unmapped owner. Empty when a file it needs is unreadable
|
||||
or this process's own uid is unmapped, since its own files then show
|
||||
up as the overflow uid too.
|
||||
"""
|
||||
try:
|
||||
with open(UID_MAP, encoding="ascii") as fh:
|
||||
ranges = [[int(n) for n in line.split()] for line in fh if line.strip()]
|
||||
if ranges == INITIAL_MAP:
|
||||
return frozenset({ROOT_UID})
|
||||
with open(OVERFLOW_UID, encoding="ascii") as fh:
|
||||
overflow = int(fh.read())
|
||||
except (OSError, ValueError):
|
||||
return frozenset()
|
||||
if os.geteuid() == overflow:
|
||||
return frozenset()
|
||||
return frozenset({overflow})
|
||||
|
||||
|
||||
def root_resolved(path: str) -> str | None:
|
||||
"""
|
||||
Args:
|
||||
path: an absolute file or directory.
|
||||
|
||||
Returns:
|
||||
The path, resolved once, when it and every directory above it are
|
||||
owned by root and writable by neither group nor others, so no other
|
||||
user can replace what it names; None otherwise. Callers keep and use
|
||||
the returned path: resolving path again can follow a link swapped in
|
||||
after this check. In the initial user namespace a component on a
|
||||
nosuid mount also yields None: a desktop user can mount an image of
|
||||
root-owned files through udisks2 or FUSE, always nosuid, while the
|
||||
system's own directories never are; every bubblewrap mount is nosuid,
|
||||
so the rule cannot apply inside one.
|
||||
"""
|
||||
owners = root_uids()
|
||||
initial = owners == {ROOT_UID}
|
||||
resolved = os.path.realpath(path)
|
||||
current = resolved
|
||||
while True:
|
||||
try:
|
||||
info = os.stat(current)
|
||||
nosuid = initial and os.statvfs(current).f_flag & os.ST_NOSUID
|
||||
except OSError:
|
||||
return None
|
||||
if info.st_uid not in owners or info.st_mode & SHARED_WRITE or nosuid:
|
||||
return None
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return resolved
|
||||
current = parent
|
||||
|
||||
|
||||
def trusted_path() -> str:
|
||||
"""
|
||||
Returns:
|
||||
The PATH entries that resolve to root-controlled directories, else
|
||||
those of os.defpath, each in its resolved form and joined by
|
||||
os.pathsep, so a symbolic link another user can retarget never stays
|
||||
on it.
|
||||
|
||||
Raises:
|
||||
RuntimeError: no entry of either qualifies; an empty PATH would make
|
||||
the hook search its working directory, the worktree.
|
||||
"""
|
||||
for source in (os.get_exec_path(), os.defpath.split(os.pathsep)):
|
||||
resolved = (root_resolved(entry) for entry in source if os.path.isabs(entry))
|
||||
entries = dict.fromkeys(
|
||||
entry for entry in resolved if entry and os.path.isdir(entry)
|
||||
)
|
||||
if entries:
|
||||
return os.pathsep.join(entries)
|
||||
raise RuntimeError(
|
||||
"no PATH entry is a root-owned directory closed to other writers"
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, run
|
||||
|
||||
from .flow import NONE_LISTED, FlowError, check_name, check_names, write_flow
|
||||
from .hooks import INIT_HOOK, defined_in, hook_command, run_hook
|
||||
from .slots import new_worktree_meta, next_slot, used_slots, write_slot
|
||||
|
||||
PORT_PREFIX = re.compile(r"^\d+/")
|
||||
HOST_AND_PATH = re.compile(r"([^:/]+)[:/](.*)")
|
||||
NETWORK_CONFIG = (
|
||||
r"^(core\.(sshcommand|gitproxy)|remote\..*\.(uploadpack|receivepack)"
|
||||
r"|credential\..*helper|url\..*\.insteadof|protocol\..*allow)$"
|
||||
)
|
||||
|
||||
|
||||
def worktree_location(url: str) -> str:
|
||||
"""
|
||||
Map a remote URL to `<host>/<owner>/<repo>`.
|
||||
|
||||
Args:
|
||||
url: remote URL in https, ssh or scp form.
|
||||
|
||||
Returns:
|
||||
The host followed by the repository path, without `.git` or a port;
|
||||
a local path loses its leading slash, so it always stays relative.
|
||||
"""
|
||||
rest = url.removesuffix(".git").split("://", 1)[-1].split("@", 1)[-1]
|
||||
match = HOST_AND_PATH.match(rest)
|
||||
if not match:
|
||||
return rest.strip("/")
|
||||
host, path = match.groups()
|
||||
return f"{host}/{PORT_PREFIX.sub('', path).strip('/')}"
|
||||
|
||||
|
||||
def _common_dir(cwd: str) -> str:
|
||||
return run(
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd, scan=True
|
||||
)
|
||||
|
||||
|
||||
def _inside(path: str, root: str) -> bool:
|
||||
real, base = os.path.realpath(path), os.path.realpath(root)
|
||||
return os.path.commonpath([real, base]) == base
|
||||
|
||||
|
||||
def _ref_exists(repo: str, ref: str) -> bool:
|
||||
try:
|
||||
run(
|
||||
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
||||
cwd=repo,
|
||||
scan=True,
|
||||
)
|
||||
except GitRunError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _commit_of(repo: str, ref: str) -> str:
|
||||
# worktree add reads a start point that begins with "-" as HEAD even after
|
||||
# "--" or --end-of-options, so it only ever gets the resolved commit id
|
||||
return run(
|
||||
["rev-parse", "--verify", "--quiet", END_OF_OPTIONS, f"{ref}^{{commit}}"],
|
||||
cwd=repo,
|
||||
scan=True,
|
||||
)
|
||||
|
||||
|
||||
def _default_base(repo: str) -> str:
|
||||
try:
|
||||
return run(
|
||||
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=repo, scan=True
|
||||
)
|
||||
except GitRunError:
|
||||
return "origin/main" if _ref_exists(repo, "origin/main") else "origin/master"
|
||||
|
||||
|
||||
def _local_branch(repo: str, ref: str) -> str:
|
||||
try:
|
||||
remotes = run(["remote"], cwd=repo, scan=True).split()
|
||||
except GitRunError:
|
||||
return ref
|
||||
owner = next((r for r in remotes if ref.startswith(f"{r}/")), None)
|
||||
return ref.removeprefix(f"{owner}/") if owner else ref
|
||||
|
||||
|
||||
def _invalid_flow(pull: list[str] | None, push: list[str] | None) -> str | None:
|
||||
try:
|
||||
for kind, names in (("pull", pull), ("push", push)):
|
||||
if names is not None:
|
||||
check_names(kind, names)
|
||||
except FlowError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _record_flow(
|
||||
root: str,
|
||||
path: str,
|
||||
branch: str,
|
||||
lists: tuple[list[str] | None, list[str] | None],
|
||||
base: str | None,
|
||||
warnings: list[str],
|
||||
) -> dict[str, list[str]] | None:
|
||||
pull, push = lists
|
||||
if pull is None or push is None:
|
||||
default = base or _default_base(root)
|
||||
try:
|
||||
check_name("base", default)
|
||||
except FlowError as exc:
|
||||
warnings.append(f"flow not set: {exc}")
|
||||
return None
|
||||
pull = [default] if pull is None else pull
|
||||
push = [_local_branch(root, default)] if push is None else push
|
||||
try:
|
||||
write_flow(new_worktree_meta(path), branch, pull, push)
|
||||
except OSError as exc:
|
||||
warnings.append(f"flow not written: {exc}")
|
||||
return None
|
||||
return {"pull": pull, "push": push}
|
||||
|
||||
|
||||
def worktree_up(
|
||||
repo_dir: str,
|
||||
branch: str,
|
||||
*,
|
||||
base: str | None,
|
||||
worktrees_dir: str,
|
||||
path: str | None,
|
||||
hooks: dict[str, dict[str, list[str]]],
|
||||
as_json: bool,
|
||||
pull: list[str] | None,
|
||||
push: list[str] | None,
|
||||
) -> int:
|
||||
"""
|
||||
Check a branch out into its own worktree under the standard path.
|
||||
|
||||
Args:
|
||||
repo_dir: any directory inside the repository.
|
||||
branch: branch to check out; created from base when it exists nowhere.
|
||||
base: start point of a new branch; None takes origin's default branch.
|
||||
worktrees_dir: root of the standard path
|
||||
`<worktrees_dir>/<host>/<owner>/<repo>/<branch>`.
|
||||
path: exact checkout path instead of the standard one, e.g. a
|
||||
directory an agent sandbox releases; None keeps the standard path.
|
||||
hooks: pinned hooks by managed repository root realpath, from the
|
||||
CLI; the worktree-init hook runs only when the repository root is
|
||||
pinned there, never from the Makefile or by origin's URL.
|
||||
as_json: print the result as JSON instead of text.
|
||||
pull: refs merged into the branch before a gate, deploy or merge, in
|
||||
order, for the worktree's flow; None takes the base.
|
||||
push: branches the branch is merged into when finished, in order;
|
||||
None takes the base.
|
||||
|
||||
Returns:
|
||||
0 when the worktree is ready, 1 when the worktree-init hook failed
|
||||
(the worktree stays), 2 when refused.
|
||||
"""
|
||||
try:
|
||||
common = _common_dir(repo_dir)
|
||||
root = os.path.dirname(common)
|
||||
url = run(["remote", "get-url", "--", "origin"], cwd=root, scan=True)
|
||||
run(["check-ref-format", "--branch", branch], cwd=root, scan=True)
|
||||
filters = local_config(root, FILTER_CONFIG)
|
||||
except GitRunError as exc:
|
||||
print(f"Refusing: {str(exc).splitlines()[0]}", file=sys.stderr)
|
||||
return 2
|
||||
if filters:
|
||||
print(
|
||||
f"Refusing: {', '.join(filters)} in the repository config would run "
|
||||
"on the checkout",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
invalid = _invalid_flow(pull, push)
|
||||
if invalid:
|
||||
print(f"Refusing: {invalid}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
location = worktree_location(url)
|
||||
if path:
|
||||
path = os.path.abspath(os.path.expanduser(path))
|
||||
else:
|
||||
standard = os.path.expanduser(worktrees_dir)
|
||||
path = os.path.join(standard, location, branch)
|
||||
if not _inside(path, standard):
|
||||
print(f"Refusing: {path} lies outside {standard}", file=sys.stderr)
|
||||
return 2
|
||||
if os.path.lexists(path):
|
||||
print(f"Refusing: {path} already exists", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
slot = next_slot(used_slots(common))
|
||||
pinned = hooks.get(os.path.realpath(root), {}).get(INIT_HOOK)
|
||||
try:
|
||||
command = hook_command(INIT_HOOK, pinned, path, slot) if pinned else None
|
||||
except RuntimeError as exc:
|
||||
print(f"Refusing: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
warnings: list[str] = []
|
||||
network = local_config(root, NETWORK_CONFIG)
|
||||
if network:
|
||||
warnings.append(f"fetch skipped: {', '.join(network)} in the repository config")
|
||||
else:
|
||||
try:
|
||||
run(
|
||||
["fetch", "--quiet", "--no-auto-maintenance", "origin"],
|
||||
cwd=root,
|
||||
scan=True,
|
||||
)
|
||||
except GitRunError as exc:
|
||||
warnings.append(f"fetch failed: {str(exc).splitlines()[0]}")
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
start: str | None = None
|
||||
if _ref_exists(root, f"refs/heads/{branch}"):
|
||||
origin, add = "existing", ["worktree", "add", "--", path, branch]
|
||||
elif _ref_exists(root, f"refs/remotes/origin/{branch}"):
|
||||
origin = f"tracking origin/{branch}"
|
||||
add = [
|
||||
"worktree",
|
||||
"add",
|
||||
"--track",
|
||||
"-b",
|
||||
branch,
|
||||
"--",
|
||||
path,
|
||||
f"origin/{branch}",
|
||||
]
|
||||
else:
|
||||
start = base or _default_base(root)
|
||||
origin = f"new from {start}"
|
||||
add = ["worktree", "add", "--no-track", "-b", branch, "--", path]
|
||||
try:
|
||||
if start is not None:
|
||||
add.append(_commit_of(root, start))
|
||||
run(add, cwd=root, scan=True)
|
||||
except GitRunError as exc:
|
||||
print(f"Refusing: {str(exc).splitlines()[0]}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
write_slot(path, slot)
|
||||
flow = _record_flow(root, path, branch, (pull, push), start or base, warnings)
|
||||
|
||||
hook = "none"
|
||||
code = 0
|
||||
if command:
|
||||
try:
|
||||
ran = run_hook(INIT_HOOK, command, path, check=False, stdout=sys.stderr)
|
||||
failure = None if ran.returncode == 0 else str(ran.returncode)
|
||||
except (RuntimeError, OSError) as exc:
|
||||
failure = str(exc)
|
||||
hook = INIT_HOOK if failure is None else f"{INIT_HOOK} failed ({failure})"
|
||||
code = 0 if failure is None else 1
|
||||
else:
|
||||
makefile = defined_in(path, INIT_HOOK)
|
||||
if makefile:
|
||||
hook = f"not pinned: {INIT_HOOK} in {makefile}"
|
||||
|
||||
result = {
|
||||
"path": path,
|
||||
"branch": branch,
|
||||
"origin": origin,
|
||||
"slot": slot,
|
||||
"hook": hook,
|
||||
"flow": flow,
|
||||
"warnings": warnings,
|
||||
}
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
for warning in warnings:
|
||||
print(f"WARN {warning}")
|
||||
for key in ("path", "branch", "origin", "slot", "hook"):
|
||||
print(f"{key:<8} {result[key]}")
|
||||
for key, names in (flow or {}).items():
|
||||
print(f"{key:<8} {', '.join(names) or NONE_LISTED}")
|
||||
return code
|
||||
@@ -8,8 +8,10 @@ from .mirror import handle_mirror_command
|
||||
from .publish import handle_publish
|
||||
from .release import handle_release
|
||||
from .repos import handle_repos_command
|
||||
from .review import handle_review
|
||||
from .tools import handle_tools_command
|
||||
from .version import handle_version
|
||||
from .worktree import handle_gc, handle_worktree
|
||||
|
||||
__all__ = [
|
||||
"handle_archive",
|
||||
@@ -17,11 +19,14 @@ __all__ = [
|
||||
"handle_changelog",
|
||||
"handle_code_scanning",
|
||||
"handle_config",
|
||||
"handle_gc",
|
||||
"handle_make",
|
||||
"handle_mirror_command",
|
||||
"handle_publish",
|
||||
"handle_release",
|
||||
"handle_repos_command",
|
||||
"handle_review",
|
||||
"handle_tools_command",
|
||||
"handle_version",
|
||||
"handle_worktree",
|
||||
]
|
||||
@@ -1,9 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch, drop_branch, open_branch
|
||||
from pkgmgr.actions.branch import (
|
||||
branch_purge,
|
||||
cleanup,
|
||||
close_branch,
|
||||
drop_branch,
|
||||
open_branch,
|
||||
tree,
|
||||
)
|
||||
from pkgmgr.actions.branch.gc import local_repo_root, main_repo_root
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
def _repo_dir(ctx: CLIContext, repo: dict) -> str:
|
||||
return (
|
||||
os.path.expanduser(str(repo["directory"]))
|
||||
if repo.get("directory")
|
||||
else get_repo_dir(ctx.repositories_base_dir, repo)
|
||||
)
|
||||
|
||||
|
||||
def _known_dirs(ctx: CLIContext) -> list[str]:
|
||||
return [_repo_dir(ctx, r) for r in ctx.all_repositories]
|
||||
|
||||
|
||||
def pinned_hooks(ctx: CLIContext) -> dict[str, dict[str, list[str]]]:
|
||||
"""
|
||||
Bind the `worktree_hooks` config to managed repositories, never to a
|
||||
remote URL, which the repository's own config sets.
|
||||
|
||||
Args:
|
||||
ctx: CLI context; each `worktree_hooks` key names the
|
||||
`<provider>/<account>/<repository>` of a `repositories` entry.
|
||||
|
||||
Returns:
|
||||
Realpath of each pinned managed repository's directory -> hook name
|
||||
-> argv; keys that match no managed repository are dropped.
|
||||
"""
|
||||
pins = ctx.config_merged["worktree_hooks"]
|
||||
return {
|
||||
os.path.realpath(_repo_dir(ctx, repo)): pins[key]
|
||||
for repo in ctx.config_merged["repositories"]
|
||||
if all(repo.get(part) for part in ("provider", "account", "repository"))
|
||||
and (key := f"{repo['provider']}/{repo['account']}/{repo['repository']}")
|
||||
in pins
|
||||
}
|
||||
|
||||
|
||||
def refresh_tree(roots: list[str]) -> None:
|
||||
for root in roots:
|
||||
try:
|
||||
tree.refresh_tree_entry(root)
|
||||
except (OSError, ValueError, KeyError, GitBaseError) as exc:
|
||||
print(f"WARN tree file not rewritten for {root}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def scan_repo_roots(args, ctx: CLIContext, local: bool = False) -> list[str]:
|
||||
"""
|
||||
Resolve the repositories a scan covers: the current one, or every
|
||||
pkgmgr-managed one with --all. Exits with 2 outside a repository.
|
||||
|
||||
Args:
|
||||
args: parsed arguments; `all` selects every managed repository.
|
||||
ctx: CLI context.
|
||||
local: resolve the current repository as scope `local` does, also in
|
||||
a submodule or with a separate git dir, refusing a mismatch.
|
||||
|
||||
Returns:
|
||||
The main worktree roots to scan.
|
||||
"""
|
||||
if not args.all:
|
||||
try:
|
||||
return [(local_repo_root if local else main_repo_root)(".")]
|
||||
except GitBaseError as exc:
|
||||
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return [d for d in _known_dirs(ctx) if os.path.exists(os.path.join(d, ".git"))]
|
||||
|
||||
|
||||
def scan_options(args) -> dict:
|
||||
return {
|
||||
"include": args.branch,
|
||||
"exclude": args.exclude,
|
||||
"merged_into": args.merged_into,
|
||||
"sizes": args.sizes,
|
||||
}
|
||||
|
||||
|
||||
def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
|
||||
roots = scan_repo_roots(args, ctx, local=scope == "local")
|
||||
code = cleanup(
|
||||
roots,
|
||||
scope=scope,
|
||||
apply=args.apply,
|
||||
as_json=args.json,
|
||||
min_age=args.min_age,
|
||||
hooks=pinned_hooks(ctx),
|
||||
**scan_options(args),
|
||||
)
|
||||
if args.apply and scope != "local":
|
||||
refresh_tree(roots)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
@@ -11,16 +113,47 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
Handle `pkgmgr branch` subcommands.
|
||||
|
||||
Currently supported:
|
||||
- pkgmgr branch open [<name>] [--base <branch>]
|
||||
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch open [<name>] [--base <branch>]
|
||||
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch cleanup [--apply] [--json] [--local] [--all] [--branch N]...
|
||||
[--exclude N]... [--merged-into REF]... [--sizes]
|
||||
[--min-age HOURS]
|
||||
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
|
||||
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||
- pkgmgr branch tree [--format json|yaml] [--output FILE]
|
||||
"""
|
||||
if args.subcommand == "tree":
|
||||
sys.exit(
|
||||
tree.branch_tree(
|
||||
_known_dirs(ctx),
|
||||
fmt=args.format,
|
||||
output=args.output,
|
||||
worktrees_root=tree.WORKTREES_ROOT,
|
||||
)
|
||||
)
|
||||
|
||||
if args.subcommand == "cleanup":
|
||||
run_cleanup(args, ctx, "local" if args.local else "branch")
|
||||
|
||||
if args.subcommand == "purge":
|
||||
roots = scan_repo_roots(args, ctx)
|
||||
code = branch_purge(
|
||||
roots,
|
||||
min_age=args.min_age,
|
||||
hooks=pinned_hooks(ctx),
|
||||
**scan_options(args),
|
||||
)
|
||||
refresh_tree(roots)
|
||||
sys.exit(code)
|
||||
|
||||
if args.subcommand == "open":
|
||||
open_branch(
|
||||
name=getattr(args, "name", None),
|
||||
base_branch=getattr(args, "base", "main"),
|
||||
cwd=".",
|
||||
)
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
if args.subcommand == "close":
|
||||
@@ -30,6 +163,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
cwd=".",
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
if args.subcommand == "drop":
|
||||
@@ -39,6 +173,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
cwd=".",
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
print(f"Unknown branch subcommand: {args.subcommand}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,7 +15,6 @@ from pkgmgr.actions.config.add import interactive_add
|
||||
from pkgmgr.actions.config.init import config_init
|
||||
from pkgmgr.actions.config.show import show_config
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
from pkgmgr.core.repository.resolve import resolve_repos
|
||||
|
||||
@@ -108,7 +108,9 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
return
|
||||
|
||||
if args.subcommand == "edit":
|
||||
run_command(f"nano {user_config_path}")
|
||||
returncode = subprocess.call(["nano", os.path.expanduser(user_config_path)])
|
||||
if returncode != 0:
|
||||
sys.exit(returncode)
|
||||
return
|
||||
|
||||
if args.subcommand == "init":
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pkgmgr.actions.review import ReviewError, write_review
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
|
||||
|
||||
def handle_review(args, ctx: CLIContext) -> None:
|
||||
try:
|
||||
path = write_review(
|
||||
cwd=os.getcwd(),
|
||||
out=args.out,
|
||||
max_file_lines=args.max_file_lines,
|
||||
base=args.base,
|
||||
notes=args.note,
|
||||
)
|
||||
except ReviewError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(Path(path).as_uri())
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch.gc import main_repo_root
|
||||
from pkgmgr.actions.gc.report import disk_report, render_text
|
||||
from pkgmgr.actions.worktree.flow_actions import (
|
||||
worktree_flow_clear,
|
||||
worktree_flow_set,
|
||||
worktree_flow_show,
|
||||
)
|
||||
from pkgmgr.actions.worktree.orphans import worktree_orphans
|
||||
from pkgmgr.actions.worktree.up import worktree_up
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
|
||||
from .branch import (
|
||||
pinned_hooks,
|
||||
refresh_tree,
|
||||
run_cleanup,
|
||||
scan_options,
|
||||
scan_repo_roots,
|
||||
)
|
||||
|
||||
MEBIBYTE = 1 << 20
|
||||
FLOW_ACTIONS = ("set", "clear")
|
||||
|
||||
|
||||
def _flow_usage(action: str, args) -> str | None:
|
||||
if action == "show" and args.path is not None:
|
||||
return f"unknown flow action {args.target!r}; use set or clear"
|
||||
if action != "set":
|
||||
flags = {
|
||||
"--pull": args.pull,
|
||||
"--push": args.push,
|
||||
"--no-pull": args.no_pull,
|
||||
"--no-push": args.no_push,
|
||||
}
|
||||
used = [flag for flag, value in flags.items() if value]
|
||||
return f"{', '.join(used)} only with `flow set`" if used else None
|
||||
for kind in ("pull", "push"):
|
||||
if bool(getattr(args, kind)) == getattr(args, f"no_{kind}"):
|
||||
return f"flow set takes either --{kind} or --no-{kind}"
|
||||
return None
|
||||
|
||||
|
||||
def _flow(args) -> int:
|
||||
action, path = (
|
||||
(args.target, args.path)
|
||||
if args.target in FLOW_ACTIONS
|
||||
else ("show", args.target)
|
||||
)
|
||||
problem = _flow_usage(action, args)
|
||||
if problem:
|
||||
print(f"Refusing: {problem}", file=sys.stderr)
|
||||
return 2
|
||||
target = path or "."
|
||||
if action == "show":
|
||||
return worktree_flow_show(target, as_json=args.json)
|
||||
if action == "set":
|
||||
code = worktree_flow_set(
|
||||
target, pull=args.pull or [], push=args.push or [], as_json=args.json
|
||||
)
|
||||
else:
|
||||
code = worktree_flow_clear(target, as_json=args.json)
|
||||
if code == 0:
|
||||
refresh_tree([main_repo_root(target)])
|
||||
return code
|
||||
|
||||
|
||||
def handle_worktree(args, ctx: CLIContext) -> None:
|
||||
"""
|
||||
Handle `pkgmgr worktree` subcommands.
|
||||
|
||||
- pkgmgr worktree up <branch> [--base REF] [--path DIR] [--pull REF ...]
|
||||
[--push BRANCH ...] [--json]
|
||||
- pkgmgr worktree flow [PATH] [--json]
|
||||
- pkgmgr worktree flow set [PATH] (--pull REF ... | --no-pull)
|
||||
(--push BRANCH ... | --no-push) [--json]
|
||||
- pkgmgr worktree flow clear [PATH] [--json]
|
||||
- pkgmgr worktree cleanup [--apply] [--json] [scan options] [--min-age HOURS]
|
||||
- pkgmgr worktree orphans --stale-days N --top N [--json] [scan options]
|
||||
"""
|
||||
if args.subcommand == "up":
|
||||
code = worktree_up(
|
||||
".",
|
||||
args.name,
|
||||
base=args.base,
|
||||
worktrees_dir=ctx.config_merged["directories"]["worktrees"],
|
||||
path=args.path,
|
||||
hooks=pinned_hooks(ctx),
|
||||
as_json=args.json,
|
||||
pull=args.pull,
|
||||
push=args.push,
|
||||
)
|
||||
refresh_tree([main_repo_root(".")])
|
||||
sys.exit(code)
|
||||
|
||||
if args.subcommand == "flow":
|
||||
sys.exit(_flow(args))
|
||||
|
||||
if args.subcommand == "cleanup":
|
||||
run_cleanup(args, ctx, "worktree")
|
||||
|
||||
if args.subcommand == "orphans":
|
||||
sys.exit(
|
||||
worktree_orphans(
|
||||
scan_repo_roots(args, ctx),
|
||||
stale_days=args.stale_days,
|
||||
top=args.top,
|
||||
as_json=args.json,
|
||||
**scan_options(args),
|
||||
)
|
||||
)
|
||||
|
||||
print(f"Unknown worktree subcommand: {args.subcommand}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def handle_gc(args, ctx: CLIContext) -> None:
|
||||
if not args.report:
|
||||
run_cleanup(args, ctx, "all")
|
||||
if args.apply:
|
||||
print(
|
||||
"--report only reads; it cannot be combined with --apply", file=sys.stderr
|
||||
)
|
||||
sys.exit(2)
|
||||
report = disk_report(
|
||||
[os.path.abspath(os.path.expanduser(root)) for root in args.root],
|
||||
scan_repo_roots(args, ctx),
|
||||
args.threshold_mb * MEBIBYTE,
|
||||
args.docker,
|
||||
)
|
||||
print(json.dumps(report, indent=2) if args.json else render_text(report))
|
||||
sys.exit(0)
|
||||
@@ -10,13 +10,16 @@ from pkgmgr.cli.commands import (
|
||||
handle_changelog,
|
||||
handle_code_scanning,
|
||||
handle_config,
|
||||
handle_gc,
|
||||
handle_make,
|
||||
handle_mirror_command,
|
||||
handle_publish,
|
||||
handle_release,
|
||||
handle_repos_command,
|
||||
handle_review,
|
||||
handle_tools_command,
|
||||
handle_version,
|
||||
handle_worktree,
|
||||
)
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.cli.proxy import maybe_handle_proxy
|
||||
@@ -69,6 +72,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
||||
handle_code_scanning(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "review":
|
||||
handle_review(args, ctx)
|
||||
return
|
||||
|
||||
commands_with_selection = {
|
||||
"install",
|
||||
"update",
|
||||
@@ -163,6 +170,14 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
||||
handle_branch(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "worktree":
|
||||
handle_worktree(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "gc":
|
||||
handle_gc(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "mirror":
|
||||
handle_mirror_command(ctx, args, selected)
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ from .changelog_cmd import add_changelog_subparser
|
||||
from .code_scanning_cmd import add_code_scanning_subparser
|
||||
from .common import SortedSubParsersAction
|
||||
from .config_cmd import add_config_subparsers
|
||||
from .gc_cmd import add_gc_subparser
|
||||
from .install_update import add_install_update_subparsers
|
||||
from .list_cmd import add_list_subparser
|
||||
from .make_cmd import add_make_subparsers
|
||||
@@ -17,7 +18,9 @@ from .mirror_cmd import add_mirror_subparsers
|
||||
from .navigation_cmd import add_navigation_subparsers
|
||||
from .publish_cmd import add_publish_subparser
|
||||
from .release_cmd import add_release_subparser
|
||||
from .review_cmd import add_review_subparser
|
||||
from .version_cmd import add_version_subparser
|
||||
from .worktree_cmd import add_worktree_subparsers
|
||||
|
||||
|
||||
def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
@@ -58,6 +61,9 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
add_navigation_subparsers(subparsers)
|
||||
|
||||
add_branch_subparsers(subparsers)
|
||||
add_review_subparser(subparsers)
|
||||
add_worktree_subparsers(subparsers)
|
||||
add_gc_subparser(subparsers)
|
||||
add_release_subparser(subparsers)
|
||||
add_publish_subparser(subparsers)
|
||||
|
||||
|
||||
@@ -96,3 +96,135 @@ def add_branch_subparsers(
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt and drop the branch directly",
|
||||
)
|
||||
|
||||
branch_cleanup = branch_subparsers.add_parser(
|
||||
"cleanup",
|
||||
help=(
|
||||
"Remove merged branches that no worktree holds, locally and on the "
|
||||
"remotes (dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
add_apply_options(branch_cleanup)
|
||||
branch_cleanup.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Delete only local branches whose tip is an ancestor of origin's "
|
||||
"remote-tracking default (else the only remote's) or a --merged-into "
|
||||
"REF, against the planned sha; refuse a repository without such a "
|
||||
"default, one git fails on, one whose holds cannot be read, one "
|
||||
"whose git dir is neither its common dir nor directly under "
|
||||
"<common>/worktrees/ and one with a symlink in or below refs, "
|
||||
"packed-refs, reftable or logs/refs, and still print the plan; skip "
|
||||
"a symbolic branch, a branch any symbolic ref of any worktree names, "
|
||||
"a branch a worktree, worktree registration, rebase or bisect holds, "
|
||||
"a --merged-into target, a branch with branch.<name>.deleteMerged "
|
||||
"false in any worktree's or the system config and the local "
|
||||
"upstream of a branch that stays, also of one --branch or --exclude "
|
||||
"leaves out; "
|
||||
"run git without inherited GIT_* variables but "
|
||||
"GIT_CEILING_DIRECTORIES; the keep config reads run once with the "
|
||||
"caller's GIT_CONFIG_* variables and once with the default system "
|
||||
"and passwd-home config, and either read's keep counts; read no "
|
||||
"forge token; "
|
||||
"never fetch, push, delete a remote branch, run docker or hooks, or "
|
||||
"refresh the branch tree file; exit 1 when an item failed, else 2 "
|
||||
"when a repository was refused, else 0; exit 2 without a plan when, "
|
||||
"without --all, the current directory is in no git repository, its "
|
||||
"git dir is neither its common dir nor directly under "
|
||||
"<common>/worktrees/, its main worktree belongs to another "
|
||||
"repository or its path or git output is not UTF-8, on --apply "
|
||||
"outside the host PID namespace, on a --merged-into REF that is not "
|
||||
"exactly one branch or remote-tracking ref (X~0, X^0, X@{0}, a sha), "
|
||||
"and on a --branch "
|
||||
"that matches nothing; meant for working clones"
|
||||
),
|
||||
)
|
||||
|
||||
branch_purge = branch_subparsers.add_parser(
|
||||
"purge",
|
||||
help="Delete unmerged or dirty branches after a confirmation per item",
|
||||
)
|
||||
|
||||
for sub in (branch_cleanup, branch_purge):
|
||||
add_scan_options(sub)
|
||||
add_min_age_option(sub)
|
||||
|
||||
branch_tree = branch_subparsers.add_parser(
|
||||
"tree",
|
||||
help=(
|
||||
"Print the branches and worktrees of every pkgmgr-managed repository "
|
||||
"with their cleanup state, without fetching"
|
||||
),
|
||||
)
|
||||
branch_tree.add_argument(
|
||||
"--format",
|
||||
choices=("json", "yaml"),
|
||||
default="json",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
branch_tree.add_argument(
|
||||
"--output",
|
||||
metavar="FILE",
|
||||
help="Write atomically to FILE instead of stdout",
|
||||
)
|
||||
|
||||
|
||||
def add_apply_options(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Execute the plan instead of only printing it",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the plan as JSON",
|
||||
)
|
||||
|
||||
|
||||
def add_scan_options(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Scan every pkgmgr-managed repository instead of the current one",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--branch",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Only this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Skip this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--merged-into",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="REF",
|
||||
help=(
|
||||
"Also count an item as merged when its commits are in REF (an "
|
||||
"integration line); REF is protected and a checkout at its tip "
|
||||
"counts as fresh; repeatable"
|
||||
),
|
||||
)
|
||||
sub.add_argument(
|
||||
"--sizes",
|
||||
action="store_true",
|
||||
help="Measure each worktree's disk usage (du) into the plan",
|
||||
)
|
||||
|
||||
|
||||
def add_min_age_option(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--min-age",
|
||||
type=float,
|
||||
metavar="HOURS",
|
||||
help="Skip items whose newest commit, worktree index or changed file is younger",
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .branch_cmd import add_apply_options, add_min_age_option, add_scan_options
|
||||
|
||||
|
||||
def add_gc_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
gc = subparsers.add_parser(
|
||||
"gc",
|
||||
help=(
|
||||
"Garbage-collect the current repository, or every one with --all: "
|
||||
"worktree cleanup and branch cleanup in one pass (dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
add_apply_options(gc)
|
||||
add_scan_options(gc)
|
||||
add_min_age_option(gc)
|
||||
gc.add_argument(
|
||||
"--report",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Print a read-only disk inventory instead of the cleanup plan: "
|
||||
"worktrees, preserved changes, --root children, core dumps, "
|
||||
"docker usage and free space per filesystem"
|
||||
),
|
||||
)
|
||||
gc.add_argument(
|
||||
"--root",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="DIR",
|
||||
help="With --report: also measure the direct children of DIR; repeatable",
|
||||
)
|
||||
gc.add_argument(
|
||||
"--threshold-mb",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="MB",
|
||||
help="With --report: list only --root children larger than MB (0: all)",
|
||||
)
|
||||
gc.add_argument(
|
||||
"--docker",
|
||||
action="store_true",
|
||||
help="With --report: also read `docker system df`",
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
number = int(value)
|
||||
if number < 1:
|
||||
raise argparse.ArgumentTypeError(f"must be a positive integer, got {value}")
|
||||
return number
|
||||
|
||||
|
||||
def add_review_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"review",
|
||||
help="Render the working copy's changes as a local HTML review page.",
|
||||
description=(
|
||||
"Write a self-contained HTML page showing the staged, unstaged and "
|
||||
"untracked changes of the current git working copy (plus the commits "
|
||||
"of REF..HEAD with --base) and print its file:// URL."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
required=True,
|
||||
metavar="FILE",
|
||||
help="Write the HTML page to FILE; parent directories are created.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-file-lines",
|
||||
required=True,
|
||||
type=_positive_int,
|
||||
metavar="N",
|
||||
help="Show at most N diff lines per file and summarise the rest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base",
|
||||
metavar="REF",
|
||||
help="Add a Commits section for REF..HEAD; omitted means no Commits section.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--note",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="FILE",
|
||||
help="Show FILE verbatim in a panel at the top; repeatable.",
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .branch_cmd import add_apply_options, add_min_age_option, add_scan_options
|
||||
|
||||
|
||||
def _add_flow_options(parser: argparse.ArgumentParser, default: str) -> None:
|
||||
parser.add_argument(
|
||||
"--pull",
|
||||
action="append",
|
||||
metavar="REF",
|
||||
help=(
|
||||
"Ref merged into the branch before a gate, deploy or merge; "
|
||||
f"repeatable, kept in order ({default})"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--push",
|
||||
action="append",
|
||||
metavar="BRANCH",
|
||||
help=(
|
||||
"Branch the branch is merged into when finished, then pushed by "
|
||||
f"the operator; repeatable, kept in order ({default})"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _add_flow_parser(worktree_subparsers: argparse._SubParsersAction) -> None:
|
||||
flow = worktree_subparsers.add_parser(
|
||||
"flow",
|
||||
help=(
|
||||
"Show, set or clear the worktree's merge flow in "
|
||||
"<git-dir>/pkgmgr/flow.yml: pull refs merged in, push branches "
|
||||
"merged into"
|
||||
),
|
||||
)
|
||||
flow.add_argument(
|
||||
"target",
|
||||
nargs="?",
|
||||
metavar="set|clear|PATH",
|
||||
help="`set` or `clear`, else the worktree to show (default: .)",
|
||||
)
|
||||
flow.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
metavar="PATH",
|
||||
help="Worktree of set or clear (default: .)",
|
||||
)
|
||||
_add_flow_options(flow, "set only, replaces the list")
|
||||
flow.add_argument(
|
||||
"--no-pull", action="store_true", help="set only: an empty pull list"
|
||||
)
|
||||
flow.add_argument(
|
||||
"--no-push", action="store_true", help="set only: an empty push list"
|
||||
)
|
||||
flow.add_argument("--json", action="store_true", help="Print the flow as JSON")
|
||||
|
||||
|
||||
def add_worktree_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
||||
worktree_parser = subparsers.add_parser(
|
||||
"worktree",
|
||||
help=(
|
||||
"Worktree utilities: create, keep a merge flow, clean up and find "
|
||||
"abandoned worktrees"
|
||||
),
|
||||
)
|
||||
worktree_subparsers = worktree_parser.add_subparsers(
|
||||
dest="subcommand",
|
||||
help="Worktree subcommands",
|
||||
required=True,
|
||||
)
|
||||
|
||||
up = worktree_subparsers.add_parser(
|
||||
"up",
|
||||
help=(
|
||||
"Check a branch out under <directories.worktrees>/<host>/<owner>/"
|
||||
"<repo>/<branch>, assign a slot and run `make worktree-init SLOT=<n>` "
|
||||
"when the project defines it"
|
||||
),
|
||||
)
|
||||
up.add_argument("name", help="Branch to check out; created when it exists nowhere")
|
||||
up.add_argument(
|
||||
"--base",
|
||||
metavar="REF",
|
||||
help="Start point of a new branch (origin's default branch when omitted)",
|
||||
)
|
||||
up.add_argument(
|
||||
"--path",
|
||||
metavar="DIR",
|
||||
help=(
|
||||
"Exact checkout path instead of the standard one, e.g. a directory "
|
||||
"an agent sandbox releases"
|
||||
),
|
||||
)
|
||||
_add_flow_options(up, "default: the base, for push its local branch")
|
||||
up.add_argument("--json", action="store_true", help="Print the result as JSON")
|
||||
|
||||
_add_flow_parser(worktree_subparsers)
|
||||
|
||||
cleanup = worktree_subparsers.add_parser(
|
||||
"cleanup",
|
||||
help=(
|
||||
"Remove clean worktrees whose commits are merged or pushed: "
|
||||
"`make worktree-fini`, compose stack, checkout; the branch stays "
|
||||
"(dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
add_apply_options(cleanup)
|
||||
add_scan_options(cleanup)
|
||||
add_min_age_option(cleanup)
|
||||
|
||||
orphans = worktree_subparsers.add_parser(
|
||||
"orphans",
|
||||
help="List merged and abandoned branches and worktrees with a proposal each",
|
||||
)
|
||||
add_scan_options(orphans)
|
||||
orphans.add_argument(
|
||||
"--stale-days",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="DAYS",
|
||||
help="Unfinished work idle for longer counts as stale-open",
|
||||
)
|
||||
orphans.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="N",
|
||||
help="Rows per class in the text table; 0 prints the counts only",
|
||||
)
|
||||
orphans.add_argument("--json", action="store_true", help="Print every row as JSON")
|
||||
+39
-1
@@ -8,8 +8,9 @@ from typing import Any
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.actions.repository.push import push_in_parallel
|
||||
from pkgmgr.actions.repository.push import push_in_parallel, push_repo_dirs
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
|
||||
@@ -185,6 +186,25 @@ def register_proxy_commands(
|
||||
"(default: min(cpu_count, 8)). Use 1 for sequential."
|
||||
),
|
||||
)
|
||||
if subcommand == "push":
|
||||
parser.add_argument(
|
||||
"--rewrite",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Deliberate history rewrite: replace commits the remote "
|
||||
"already has (amend, squash, drop); leases still apply."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sign",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Push unsigned commits as they are instead of re-signing "
|
||||
"them; leases still apply."
|
||||
),
|
||||
)
|
||||
if subcommand == "clone":
|
||||
parser.add_argument(
|
||||
"--clone-mode",
|
||||
@@ -209,6 +229,22 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
||||
if args.command not in all_proxy_subcommands:
|
||||
return False
|
||||
|
||||
if args.command == "push" and not _proxy_has_explicit_selection(args):
|
||||
try:
|
||||
toplevel = run(["rev-parse", "--show-toplevel"])
|
||||
except GitRunError:
|
||||
print("[ERROR] Current directory is not inside a git checkout.")
|
||||
sys.exit(1)
|
||||
push_repo_dirs(
|
||||
[(toplevel, toplevel)],
|
||||
args.extra_args,
|
||||
args.preview,
|
||||
not args.no_sign,
|
||||
jobs=args.jobs,
|
||||
rewrite=args.rewrite,
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
# Default semantics: without explicit selection → repo of current folder.
|
||||
if _proxy_has_explicit_selection(args):
|
||||
selected = get_selected_repos(args, ctx.all_repositories)
|
||||
@@ -251,7 +287,9 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
||||
ctx.all_repositories,
|
||||
args.extra_args,
|
||||
args.preview,
|
||||
not args.no_sign,
|
||||
jobs=args.jobs,
|
||||
rewrite=args.rewrite,
|
||||
)
|
||||
else:
|
||||
exec_proxy_command(
|
||||
|
||||
@@ -4,6 +4,13 @@ directories:
|
||||
repositories: ~/Repositories/
|
||||
workspaces: ~/Workspaces/
|
||||
binaries: ~/.local/bin/
|
||||
worktrees: ~/.local/share/worktrees/
|
||||
# worktree_hooks: <provider>/<account>/<repository> of an entry under
|
||||
# repositories -> worktree-init / worktree-fini -> argv, with {path} and {slot}
|
||||
# replaced; it runs only for that entry's directory, never selected by origin's
|
||||
# URL, and without an entry no hook runs, whatever the repository's Makefile
|
||||
# defines
|
||||
worktree_hooks: {}
|
||||
repositories:
|
||||
- account: kevinveenbirkenbach
|
||||
description: A configurable Python package manager that automates repository tasks—including cloning, installation, updates, and status reporting—based on a YAML configuration file for streamlined software management which gives you access to the Kevin Veen-Birkenbach Code Universe.
|
||||
|
||||
@@ -144,6 +144,7 @@ def _load_layer_dir(
|
||||
{
|
||||
"directories": {...},
|
||||
"repositories": [...],
|
||||
<other key>: dicts deep-merged, other values from the last file,
|
||||
}
|
||||
"""
|
||||
defaults: dict[str, Any] = {"directories": {}, "repositories": []}
|
||||
@@ -165,21 +166,16 @@ def _load_layer_dir(
|
||||
|
||||
for path in yaml_files:
|
||||
data = _load_yaml_file(path)
|
||||
category_name = path.stem
|
||||
|
||||
dirs = data.get("directories")
|
||||
if isinstance(dirs, dict):
|
||||
defaults.setdefault("directories", {})
|
||||
_deep_merge(defaults["directories"], dirs)
|
||||
|
||||
repos = data.get("repositories")
|
||||
repos = data.pop("repositories", None)
|
||||
if isinstance(repos, list):
|
||||
defaults.setdefault("repositories", [])
|
||||
_merge_repo_lists(
|
||||
defaults["repositories"],
|
||||
repos,
|
||||
category_name=category_name,
|
||||
category_name=path.stem,
|
||||
)
|
||||
if not isinstance(data.get("directories", {}), dict):
|
||||
del data["directories"]
|
||||
_deep_merge(defaults, data)
|
||||
|
||||
return defaults
|
||||
|
||||
@@ -229,7 +225,8 @@ def load_config(user_config_path: str) -> dict[str, Any]:
|
||||
Steps:
|
||||
1. Determine ~/.config/pkgmgr/ (or dir of user_config_path).
|
||||
2. Load all *.yml/*.yaml in that dir (except the user config file) as defaults.
|
||||
3. If nothing found, fall back to package defaults.
|
||||
3. If nothing found, fall back to package defaults. Package keys other
|
||||
than directories and repositories always lie under the layers.
|
||||
4. Load the user config file (if present).
|
||||
5. Merge:
|
||||
- directories: deep-merge (defaults <- user)
|
||||
@@ -247,13 +244,14 @@ def load_config(user_config_path: str) -> dict[str, Any]:
|
||||
|
||||
# 1+2) Defaults from user directory
|
||||
defaults = _load_layer_dir(config_dir, skip_filename=user_cfg_name)
|
||||
package = _load_defaults_from_package_or_project()
|
||||
|
||||
# 3) Fallback to package defaults
|
||||
if not defaults["directories"] and not defaults["repositories"]:
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
defaults.setdefault("directories", {})
|
||||
defaults.setdefault("repositories", [])
|
||||
defaults["directories"] = package["directories"]
|
||||
defaults["repositories"] = package["repositories"]
|
||||
del package["directories"], package["repositories"]
|
||||
defaults = _deep_merge(package, defaults)
|
||||
|
||||
# 4) User config
|
||||
user_cfg: dict[str, Any] = {}
|
||||
|
||||
@@ -19,7 +19,6 @@ from .pull import GitPullError, pull
|
||||
from .pull_args import GitPullArgsError, pull_args
|
||||
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
|
||||
from .push import GitPushError, push
|
||||
from .push_args import GitPushArgsError, push_args
|
||||
from .push_upstream import GitPushUpstreamError, push_upstream
|
||||
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
||||
from .tag_annotated import GitTagAnnotatedError, tag_annotated
|
||||
@@ -43,7 +42,6 @@ __all__ = [
|
||||
"GitPullArgsError",
|
||||
"GitPullError",
|
||||
"GitPullFfOnlyError",
|
||||
"GitPushArgsError",
|
||||
"GitPushError",
|
||||
"GitPushUpstreamError",
|
||||
"GitSetRemoteUrlError",
|
||||
@@ -67,7 +65,6 @@ __all__ = [
|
||||
"pull_args",
|
||||
"pull_ff_only",
|
||||
"push",
|
||||
"push_args",
|
||||
"push_upstream",
|
||||
"set_remote_url",
|
||||
"tag_annotated",
|
||||
|
||||
@@ -11,7 +11,7 @@ class GitDeleteLocalBranchError(GitCommandError):
|
||||
def delete_local_branch(branch: str, cwd: str = ".", force: bool = False) -> None:
|
||||
flag = "-D" if force else "-d"
|
||||
try:
|
||||
run(["branch", flag, branch], cwd=cwd)
|
||||
run(["branch", flag, "--", branch], cwd=cwd)
|
||||
except GitRunError as exc:
|
||||
raise GitDeleteLocalBranchError(
|
||||
f"Failed to delete local branch {branch!r} (flag {flag}).",
|
||||
|
||||
@@ -10,7 +10,7 @@ class GitDeleteRemoteBranchError(GitCommandError):
|
||||
|
||||
def delete_remote_branch(remote: str, branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["push", remote, "--delete", branch], cwd=cwd)
|
||||
run(["push", "--delete", "--", remote, branch], cwd=cwd)
|
||||
except GitRunError as exc:
|
||||
raise GitDeleteRemoteBranchError(
|
||||
f"Failed to delete remote branch {branch!r} on {remote!r}.",
|
||||
|
||||
@@ -20,11 +20,9 @@ def push(
|
||||
Push a ref to a remote, optionally forced.
|
||||
|
||||
Equivalent to:
|
||||
git push <remote> <ref> [--force]
|
||||
git push [--force] -- <remote> <ref>
|
||||
"""
|
||||
args = ["push", remote, ref]
|
||||
if force:
|
||||
args.append("--force")
|
||||
args = ["push", *(["--force"] if force else []), "--", remote, ref]
|
||||
|
||||
try:
|
||||
run(args, cwd=cwd, preview=preview)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitCommandError, GitRunError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPushArgsError(GitCommandError):
|
||||
"""Raised when `git push` with arbitrary args fails."""
|
||||
|
||||
|
||||
def push_args(
|
||||
args: list[str] | None = None,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` with caller-provided arguments.
|
||||
|
||||
Examples:
|
||||
[] -> git push
|
||||
["--force"] -> git push --force
|
||||
["origin", "main"] -> git push origin main
|
||||
["-u", "origin", "feature"] -> git push -u origin feature
|
||||
"""
|
||||
extra = args or []
|
||||
try:
|
||||
run(["push", *extra], cwd=cwd, preview=preview)
|
||||
except GitRunError as exc:
|
||||
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
|
||||
raise GitPushArgsError(
|
||||
(
|
||||
f"Failed to run `git push` with args={extra!r} "
|
||||
f"in cwd={cwd!r}.\n{details}"
|
||||
).rstrip(),
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
@@ -19,10 +19,10 @@ def push_upstream(
|
||||
Push a branch and set upstream tracking.
|
||||
|
||||
Equivalent to:
|
||||
git push -u <remote> <branch>
|
||||
git push -u -- <remote> <branch>
|
||||
"""
|
||||
try:
|
||||
run(["push", "-u", remote, branch], cwd=cwd, preview=preview)
|
||||
run(["push", "-u", "--", remote, branch], cwd=cwd, preview=preview)
|
||||
except GitRunError as exc:
|
||||
raise GitPushUpstreamError(
|
||||
f"Failed to push branch {branch!r} to {remote!r} with upstream tracking.",
|
||||
|
||||
@@ -20,6 +20,7 @@ from .get_tags_at_ref import GitTagsAtRefQueryError, get_tags_at_ref
|
||||
from .get_upstream_ref import get_upstream_ref
|
||||
from .list_remotes import list_remotes
|
||||
from .list_tags import list_tags
|
||||
from .local_config import FILTER_CONFIG, UNSAFE_CONFIG, local_config
|
||||
from .probe_remote_reachable import (
|
||||
probe_remote_reachable,
|
||||
probe_remote_reachable_detail,
|
||||
@@ -27,6 +28,8 @@ from .probe_remote_reachable import (
|
||||
from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch
|
||||
|
||||
__all__ = [
|
||||
"FILTER_CONFIG",
|
||||
"UNSAFE_CONFIG",
|
||||
"GitBaseBranchNotFoundError",
|
||||
"GitChangelogQueryError",
|
||||
"GitLatestSigningKeyQueryError",
|
||||
@@ -46,6 +49,7 @@ __all__ = [
|
||||
"get_upstream_ref",
|
||||
"list_remotes",
|
||||
"list_tags",
|
||||
"local_config",
|
||||
"probe_remote_reachable",
|
||||
"probe_remote_reachable_detail",
|
||||
"resolve_base_branch",
|
||||
|
||||
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
from ..run import run
|
||||
|
||||
|
||||
def list_remotes(cwd: str = ".") -> list[str]:
|
||||
def list_remotes(cwd: str = ".", scan: bool = False) -> list[str]:
|
||||
"""
|
||||
Return a list of configured git remotes (e.g. ['origin', 'upstream']).
|
||||
|
||||
Raises GitBaseError if the command fails.
|
||||
scan=True runs git hardened. Raises GitBaseError if the command fails.
|
||||
"""
|
||||
output = run(["remote"], cwd=cwd)
|
||||
output = run(["remote"], cwd=cwd, scan=scan)
|
||||
if not output:
|
||||
return []
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitRunError
|
||||
from ..run import run
|
||||
|
||||
FILTER_CONFIG = r"^filter\."
|
||||
UNSAFE_CONFIG = (
|
||||
r"^(core\.(sshcommand|gitproxy|askpass|fsmonitor|alternaterefscommand)$"
|
||||
r"|credential\.(.+\.)?helper$|gpg\.(.+\.)?program$|gpg\.ssh\.defaultkeycommand$"
|
||||
r"|protocol\.|hook\.|gc\.recentobjectshook$"
|
||||
r"|http\.(.+\.)?(proxy|sslverify|sslcainfo|sslcapath|cookiefile|savecookies)$"
|
||||
r"|remote\..+\.(receivepack|uploadpack|vcs|proxy)$"
|
||||
r"|url\..+\.(insteadof|pushinsteadof)$)"
|
||||
)
|
||||
LOCAL_SCOPES = ("local", "worktree")
|
||||
|
||||
|
||||
def local_config(path: str, pattern: str) -> list[str]:
|
||||
"""
|
||||
List the config keys matching pattern that the repository itself sets.
|
||||
|
||||
Args:
|
||||
path: any directory inside the repository.
|
||||
pattern: `git config --get-regexp` pattern over the canonical key names.
|
||||
|
||||
Returns:
|
||||
Each matching key set in the local or worktree scope, in config order;
|
||||
global values never count, system config is not read.
|
||||
"""
|
||||
try:
|
||||
out = run(
|
||||
["config", "--show-scope", "--get-regexp", pattern], cwd=path, scan=True
|
||||
)
|
||||
except GitRunError as exc:
|
||||
if exc.returncode == 1:
|
||||
return []
|
||||
raise
|
||||
keys: list[str] = []
|
||||
for line in out.splitlines():
|
||||
scope, _, rest = line.partition("\t")
|
||||
if scope in LOCAL_SCOPES:
|
||||
keys.append(rest.split(" ", 1)[0])
|
||||
return keys
|
||||
@@ -37,12 +37,13 @@ def resolve_base_branch(
|
||||
preferred: str = "main",
|
||||
fallback: str = "master",
|
||||
cwd: str = ".",
|
||||
scan: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the base branch to use.
|
||||
|
||||
Try `preferred` first (default: main),
|
||||
fall back to `fallback` (default: master).
|
||||
fall back to `fallback` (default: master); scan=True runs git hardened.
|
||||
|
||||
Raises GitBaseBranchNotFoundError if neither exists.
|
||||
Raises GitRunError for other git failures (e.g., not a git repository).
|
||||
@@ -51,7 +52,7 @@ def resolve_base_branch(
|
||||
|
||||
for candidate in (preferred, fallback):
|
||||
try:
|
||||
run(["rev-parse", "--verify", candidate], cwd=cwd)
|
||||
run(["rev-parse", "--verify", candidate], cwd=cwd, scan=scan)
|
||||
return candidate
|
||||
except GitRunError as exc:
|
||||
if _is_branch_missing_error(exc):
|
||||
|
||||
+166
-3
@@ -1,35 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .errors import GitNotRepositoryError, GitRunError
|
||||
|
||||
SCAN_FLAGS = (
|
||||
"--no-pager",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
# hook.<name>.command hooks from the config ignore core.hooksPath
|
||||
"-c",
|
||||
"hook.reference-transaction.enabled=false",
|
||||
"-c",
|
||||
"hook.prepare-commit-msg.enabled=false",
|
||||
"-c",
|
||||
"hook.post-commit.enabled=false",
|
||||
"-c",
|
||||
"hook.post-rewrite.enabled=false",
|
||||
"-c",
|
||||
"hook.post-index-change.enabled=false",
|
||||
"-c",
|
||||
"hook.pre-push.enabled=false",
|
||||
"-c",
|
||||
"hook.post-checkout.enabled=false",
|
||||
"-c",
|
||||
"core.pager=cat",
|
||||
"-c",
|
||||
"gc.auto=0",
|
||||
"-c",
|
||||
"maintenance.auto=false",
|
||||
"-c",
|
||||
"log.showSignature=false",
|
||||
)
|
||||
SCAN_ENV = {
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_NO_LAZY_FETCH": "1",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_ASKPASS": "/bin/false",
|
||||
"LC_ALL": "C",
|
||||
}
|
||||
SCAN_UNSET = (
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_EXTERNAL_DIFF",
|
||||
"GIT_PAGER",
|
||||
"PAGER",
|
||||
)
|
||||
STRICT_KEEP = ("GIT_CEILING_DIRECTORIES",)
|
||||
CONFIG_READ_KEEP = re.compile(
|
||||
r"GIT_CONFIG_(GLOBAL|SYSTEM|PARAMETERS|COUNT|KEY_\d+|VALUE_\d+)"
|
||||
)
|
||||
CALLER_CONFIG = "caller"
|
||||
DEFAULT_CONFIG = "default"
|
||||
CONFIG_VIEWS = (CALLER_CONFIG, DEFAULT_CONFIG)
|
||||
SCAN_TIMEOUT = 60
|
||||
# ref and remote names come from repositories an agent may write: one named
|
||||
# like --output=<path> would otherwise make rev-list create that file
|
||||
END_OF_OPTIONS = "--end-of-options"
|
||||
_STRICT_ENV: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"pkgmgr_git_strict_env", default=False
|
||||
)
|
||||
|
||||
|
||||
def _is_not_repo_error(stderr: str) -> bool:
|
||||
msg = (stderr or "").lower()
|
||||
return "not a git repository" in msg
|
||||
|
||||
|
||||
@contextmanager
|
||||
def strict_env() -> Iterator[None]:
|
||||
token = _STRICT_ENV.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_STRICT_ENV.reset(token)
|
||||
|
||||
|
||||
def _strict_keeps(key: str, caller: bool) -> bool:
|
||||
return key in STRICT_KEEP or (
|
||||
caller and CONFIG_READ_KEEP.fullmatch(key) is not None
|
||||
)
|
||||
|
||||
|
||||
def _passwd_home() -> str | None:
|
||||
try:
|
||||
return pwd.getpwuid(os.getuid()).pw_dir
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _scan_env(strict: bool = False, view: str | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Build the environment of a scan or strict git call.
|
||||
|
||||
Args:
|
||||
strict: drop every inherited `GIT_*` variable but STRICT_KEEP.
|
||||
view: None, CALLER_CONFIG or DEFAULT_CONFIG, as run takes it.
|
||||
|
||||
Returns:
|
||||
The inherited environment without SCAN_UNSET, plus SCAN_ENV; for a
|
||||
config view with the system config; for DEFAULT_CONFIG also without
|
||||
any `GIT_*` but STRICT_KEEP and XDG_CONFIG_HOME, HOME set to the
|
||||
passwd home (unset without a passwd entry).
|
||||
"""
|
||||
caller = view == CALLER_CONFIG
|
||||
scrub = strict or view == DEFAULT_CONFIG
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key not in SCAN_UNSET
|
||||
and not (scrub and key.startswith("GIT_") and not _strict_keeps(key, caller))
|
||||
}
|
||||
env = {**env, **SCAN_ENV}
|
||||
if view is None:
|
||||
return env
|
||||
del env["GIT_CONFIG_NOSYSTEM"]
|
||||
if view == DEFAULT_CONFIG:
|
||||
env.pop("XDG_CONFIG_HOME", None)
|
||||
env.pop("HOME", None)
|
||||
home = _passwd_home()
|
||||
if home:
|
||||
env["HOME"] = home
|
||||
return env
|
||||
|
||||
|
||||
def run(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
scan: bool = False,
|
||||
input_text: str | None = None,
|
||||
config_view: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run a Git command and return its stdout as a stripped string.
|
||||
|
||||
If preview=True, the command is printed but NOT executed.
|
||||
Args:
|
||||
args: the git arguments.
|
||||
cwd: directory to run in.
|
||||
preview: print the command instead of running it.
|
||||
scan: read a repository an agent may have written: no fsmonitor, hook,
|
||||
pager, lazy fetch, replace object, system config or prompt, stdin
|
||||
closed, a timeout. Inside strict_env() every call, scan or not,
|
||||
also runs without any inherited `GIT_*` variable but
|
||||
`GIT_CEILING_DIRECTORIES`, which only narrows discovery.
|
||||
input_text: text fed to stdin, e.g. for `cat-file --batch-check`;
|
||||
replaces the closed stdin of scan mode.
|
||||
config_view: for a pure config read, the config it sees, both with
|
||||
the system config: CALLER_CONFIG also keeps, inside strict_env(),
|
||||
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS`,
|
||||
`GIT_CONFIG_COUNT` and its `GIT_CONFIG_KEY_<n>` and
|
||||
`GIT_CONFIG_VALUE_<n>`; DEFAULT_CONFIG reads the default system
|
||||
and passwd-home global config whatever the environment says.
|
||||
|
||||
Raises GitRunError (or a subclass) if execution fails.
|
||||
Raises GitRunError (or a subclass) if execution fails or times out.
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
cmd = ["git", *SCAN_FLAGS, *args] if scan else ["git"] + args
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
|
||||
return ""
|
||||
|
||||
strict = _STRICT_ENV.get()
|
||||
hardened: dict = (
|
||||
{
|
||||
"env": _scan_env(strict, config_view),
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"timeout": SCAN_TIMEOUT,
|
||||
}
|
||||
if scan
|
||||
else ({"env": _scan_env(True, config_view)} if strict else {})
|
||||
)
|
||||
if input_text is not None:
|
||||
hardened.pop("stdin", None)
|
||||
hardened["input"] = input_text
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
@@ -37,7 +194,13 @@ def run(
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**hardened,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
err = GitRunError(f"Git command timed out in {cwd!r}: {cmd_str}")
|
||||
err.cwd, err.cmd, err.cmd_str, err.returncode = cwd, cmd, cmd_str, -1
|
||||
err.stdout, err.stderr = "", ""
|
||||
raise err from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr or ""
|
||||
stdout = exc.stdout or ""
|
||||
|
||||
@@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_cargo_version(repo_dir: str) -> str | None:
|
||||
"""
|
||||
Read [package].version from Cargo.toml in repo_dir, if present.
|
||||
"""
|
||||
path = os.path.join(repo_dir, "Cargo.toml")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
version = (tomllib.load(f).get("package") or {}).get("version")
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
return version.strip() if isinstance(version, str) and version.strip() else None
|
||||
|
||||
|
||||
def read_changelog_version(repo_dir: str) -> str | None:
|
||||
"""
|
||||
Read the newest release from CHANGELOG.md ("## [X.Y.Z] - date").
|
||||
"""
|
||||
path = os.path.join(repo_dir, "CHANGELOG.md")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
match = re.search(r"(?m)^##\s+\[(v?\d+\.\d+\.\d+)\]", f.read())
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
return match.group(1).strip() if match else None
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user