Compare commits

..
22 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 08738d812e Release version 2.3.0
CI / test-unit (push) Blocked by required conditions
CI / test-integration (push) Blocked by required conditions
CI / test-env-virtual (push) Blocked by required conditions
CI / test-env-nix (push) Blocked by required conditions
CI / test-e2e (push) Blocked by required conditions
CI / test-virgin-user (push) Blocked by required conditions
CI / test-virgin-root (push) Blocked by required conditions
CI / lint-shell (push) Blocked by required conditions
CI / lint-python (push) Blocked by required conditions
Mark stable commit / mark-stable (push) Waiting to run
CodeQL Advanced / Check security (none, actions) (push) Waiting to run
Test Units / test-unit (push) Waiting to run
Test Code Integration / test-integration (push) Waiting to run
Test OS Containers / test-env-virtual (arch) (push) Waiting to run
Test OS Containers / test-env-virtual (centos) (push) Waiting to run
Test OS Containers / test-env-virtual (debian) (push) Waiting to run
Test OS Containers / test-env-virtual (fedora) (push) Waiting to run
Test OS Containers / test-env-virtual (ubuntu) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Waiting to run
Test End-To-End / test-e2e (arch) (push) Waiting to run
Test End-To-End / test-e2e (centos) (push) Waiting to run
Test End-To-End / test-e2e (debian) (push) Waiting to run
Test End-To-End / test-e2e (fedora) (push) Waiting to run
Test End-To-End / test-e2e (ubuntu) (push) Waiting to run
Test Virgin User / test-virgin-user (arch) (push) Waiting to run
Test Virgin User / test-virgin-user (centos) (push) Waiting to run
Test Virgin User / test-virgin-user (fedora) (push) Waiting to run
Test Virgin User / test-virgin-user (ubuntu) (push) Waiting to run
Test Virgin Root / test-virgin-root (arch) (push) Waiting to run
Test Virgin Root / test-virgin-root (debian) (push) Waiting to run
Test Virgin Root / test-virgin-root (ubuntu) (push) Waiting to run
ShellCheck / lint-shell (push) Waiting to run
Docker Linter / Lint Dockerfile (push) Waiting to run
CI / security-codeql (push) Blocked by required conditions
CI / lint-docker (push) Blocked by required conditions
CodeQL Advanced / Check security (none, python) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Waiting to run
Test Virgin User / test-virgin-user (debian) (push) Waiting to run
Test Virgin Root / test-virgin-root (centos) (push) Waiting to run
Test Virgin Root / test-virgin-root (fedora) (push) Waiting to run
Ruff (Python code sniffer) / lint-python (push) Waiting to run
2026-11-01 15:14:53 +01:00
Kevin Veen-Birkenbach 21cecf64fb feat(branch-gc): add --merged-into, --sizes and --min-age 2026-11-01 12:54:19 +01:00
Kevin Veen-Birkenbach d1a951ee94 Release version 2.2.0
Mark stable commit / mark-stable (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 f40cc5cfde feat(push): add --no-sign to push unsigned commits
pkgmgr push --no-sign skips the re-sign rebase and the unsigned-range
refusal, so a checkout without a usable signing key can still sync the
fetch URL and every mirror pushurl. Leases and the git cherry guard
still apply. push_branch takes sign as a required keyword; pkgmgr
release and release --retry pass sign=True, so releases stay signed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 944b4cb663 ci(publish): build container images on native arch runners
Each distro now builds amd64 on ubuntu-latest and arm64 on
ubuntu-24.04-arm instead of emulating arm64 under QEMU. image.sh gains
--platform, which pushes one <version>-<arch> tag, and --merge, which
joins those tags under the release, stable and alias tags with
buildx imagetools. The workflow splits into prepare, build and merge
jobs; scripts/build/publish.sh follows the same sequence locally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 919e6a6434 feat(push): re-sign and lease every mirror pushurl
pkgmgr push now signs unpushed commits like git-sign-push and pushes
each pushurl with its own --force-with-lease: the fetch URL against the
tracking ref, every mirror against its own fetched tip, each only after
git cherry shows no commit exists only there. --rewrite allows a
deliberate history rewrite. Without a repository selection it pushes
the current checkout, including worktrees outside the base dir.
pkgmgr release and release --retry sign before tagging and push the
branch and tag through the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
Kevin Veen-Birkenbach 4a4bf137ac Release version 2.1.1
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-31 18:47:35 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 ea82d91b75 fix(release): skip pyproject.toml without a static project version
A pyproject.toml that holds only tool config (no [project] table) or lists "version" in [project].dynamic now prints an INFO line and is skipped, like a missing file. A [project] table with neither a version nor a dynamic entry still raises.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 18:42:41 +01:00
Kevin Veen-Birkenbach 1c0a4abcbb Release version 2.1.0
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-31 14:38:54 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 810d27badc feat(release): start from the highest version across all sources
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
The release base used to be the latest v* tag only. It is now the highest version found in that tag and in every file the release writes: pyproject.toml, CHANGELOG.md, flake.nix, PKGBUILD, the RPM spec, debian/changelog and Cargo.toml. A lagging file can no longer lower the base and a file ahead of the tag raises it; when the sources disagree the release says so and names the version it starts from. Repos whose sources agree release exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:07 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 d823a42edc feat(release): bump the root crate in Cargo.toml and Cargo.lock
A repo whose Cargo.toml has a [package] section now gets its version set to the release version, and the matching local [[package]] entry in Cargo.lock (same name, no source line) follows in the same release commit, so cargo build --locked keeps passing. Workspaces without [package] and repos without Cargo.toml release as before; a Cargo.lock without the local entry aborts the release before any file is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:07 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 168d59e1b2 feat(branch): add gc and purge for merged and abandoned branches
pkgmgr branch gc lists every branch and worktree of the repo (or of all managed repos with --all) together with the compose stacks found via com.docker.compose.project.working_dir. Branches whose tips are merged (ancestor of the default branch off its first-parent line, or head SHA of a merged GitHub PR) and whose worktrees are clean are removed with --apply: compose down -v plus the project's images, the worktree (foreign-owned files through a throwaway alpine container, metadata only after the checkout is gone), the local branch and the branch on every remote via push --force-with-lease. Default, protected, open-PR, busy and locked items are skipped; every item is re-checked right before it is removed. pkgmgr branch purge handles unmerged or dirty items with a confirmation per item.

Both refuse to act outside the host PID namespace, where processes and worktree locks cannot be verified. The fetch skips auto-maintenance, whose worktree-prune task deletes the metadata of sandboxed worktrees.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:06 +01:00
Kevin Veen-Birkenbach 579ded3438 Release version 2.0.5
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-29 13:57:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 eb17cddd21 fix(config): open nano on the terminal in config edit
pkgmgr config edit ran nano through run_command, which pipes stdout and stderr to capture them, so nano aborted with 'Standard output is not a terminal'. The editor now runs with subprocess.call and the inherited terminal, and a failing editor exits with its code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:45 +01:00
Kevin Veen-Birkenbach c7b070a62b Release version 2.0.4
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-19 10:06:02 +02:00
Kevin Veen-BirkenbachandClaude Opus 5.5 20cd174a1f fix(release): date changelog entries without a ruff-version-dependent noqa
ruff 0.16 flags date.today() (DTZ011) while 0.17 drops that rule from its defaults and flags the noqa as unused (RUF100), so CI's unpinned ruff failed lint-python. datetime.now().astimezone().date() yields the same local date and passes both, matching debian.py and rpm_changelog.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 09:58:31 +02:00
Kevin Veen-Birkenbach ae4bfb5965 Merge pull request #7 from kevinveenbirkenbach/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 4 to 7
2026-10-19 09:36:25 +02:00
Kevin Veen-Birkenbach 4f71680bb1 Merge pull request #6 from kevinveenbirkenbach/dependabot/github_actions/hadolint/hadolint-action-3.5.0
build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
2026-10-19 09:35:34 +02:00
Kevin Veen-Birkenbach c78f5ac75e Release version 2.0.3
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-18 23:33:52 +02:00
Kevin Veen-BirkenbachandClaude Opus 5.5 32e896fd09 fix(release): lint the changelog entry inside the file it lands in
The entry was linted alone, so rules that compare it with the rest of
CHANGELOG.md never fired. infinito-nexus 16.0.0 passed this check with
"-" bullets on top of older "*" entries, and the repository's own
markdown lint then reported 400 MD004 findings and refused the push.

update_changelog now also lints the merged file and reports only the
findings the existing file does not already produce
(lint_changelog_context), so pre-existing issues are not blamed on the
entry. markdownlint-cli2 runs with --no-globs, so a target config with
its own globs no longer lints the whole repository and blocks a release
on unrelated files. Without markdownlint-cli2 the built-in check now
requires "-" list markers (MD004 dash), the strictest style.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 23:31:22 +02:00
dependabot[bot] d24eb0155c build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 00:15:09 +00:00
dependabot[bot] c6e866b2c9 build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
Bumps [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) from 3.3.0 to 3.5.0.
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2332a7b74a6de0dda2e2221d575162eba76ba5e5...06be81baf89a55ffd0e24b8f04a4185738dd3387)

---
updated-dependencies:
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 00:14:01 +00:00
52 changed files with 3078 additions and 147 deletions

No files matched your search

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