Compare commits

..
30 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 4a4bf137ac Release version 2.1.1
CI / security-codeql (push) Blocked by required conditions
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
CI / lint-docker (push) Blocked by required conditions
Mark stable commit / mark-stable (push) Waiting to run
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 / security-codeql (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 / lint-docker (push) Canceled after 0s
Mark stable commit / mark-stable (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
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
CI / lint-docker (push) Blocked by required conditions
Docker Linter / Lint Dockerfile (push) Blocked by required conditions
CodeQL Advanced / Check security (none, actions) (push) Blocked by required conditions
CodeQL Advanced / Check security (none, python) (push) Blocked by required conditions
Test Units / test-unit (push) Blocked by required conditions
Test Code Integration / test-integration (push) Blocked by required conditions
Test OS Containers / test-env-virtual (arch) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (centos) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (debian) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (fedora) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (ubuntu) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Blocked by required conditions
Test End-To-End / test-e2e (arch) (push) Blocked by required conditions
Test End-To-End / test-e2e (centos) (push) Blocked by required conditions
Test End-To-End / test-e2e (debian) (push) Blocked by required conditions
Test End-To-End / test-e2e (fedora) (push) Blocked by required conditions
Test End-To-End / test-e2e (ubuntu) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (arch) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (centos) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (fedora) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (ubuntu) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (arch) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (centos) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (debian) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (ubuntu) (push) Blocked by required conditions
ShellCheck / lint-shell (push) Blocked by required conditions
Ruff (Python code sniffer) / lint-python (push) Blocked by required conditions
CI / security-codeql (push) Blocked by required conditions
Test Virgin User / test-virgin-user (debian) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (fedora) (push) Blocked by required conditions
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, python) (push) Canceled after 0s
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 (fedora) (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 (fedora) (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 Virgin Nix (flake only) / test-env-nix (arch) (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 (centos) (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
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (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
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, python) (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 (fedora) (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 (fedora) (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 Virgin Nix (flake only) / test-env-nix (arch) (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 (centos) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (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
CI / lint-docker (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
CI / security-codeql (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, python) (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 (fedora) (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 (fedora) (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 Virgin Nix (flake only) / test-env-nix (arch) (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 (centos) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (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
CI / lint-docker (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
CI / security-codeql (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
Kevin Veen-Birkenbach 1f6fe52855 Release version 2.0.2
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Update flake.lock / Update flake.lock (push) Has been cancelled
2026-10-01 17:47:12 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 156ac595e6 test: hold three tests against the code they are supposed to cover
All three failed on behaviour that is correct, which is why they had to move
rather than the code.

Two date from aa48981, which moved the default configs into the package. The
loader test still wrote them beside it, into site-packages, and read its own
stale comment as proof that the loader looks there. The cli-update test
demanded a fallback onto that same directory; on a wheel install it belongs
to other packages, so the test now pins the opposite and keeps the boundary.

The third read a config.yaml in the destination to show the source one was
not copied over it. The code never writes that file, so the read died before
it could show anything; the absence is the assertion.

The debian installer test stubbed which() for apt-get and dpkg-buildpackage
but not for sudo, so _privileged_prefix returned None and the installer
warned and left before reaching the apt-get calls the test was waiting for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:43 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 b778ad7dbe fix(install): treat install-* as what it is, not as an install target
The detection accepted any target starting with install-, while the run
below it has always called plain make install. A repository carrying
install-python and no install therefore pulled the installer in and died on
"No rule to make target 'install'" instead of being skipped, which is what
the surrounding branch is there for. meta-infinite-graph, universal-logout
and file-dedupe all failed this way, and with them two e2e tests.

775c301 added the parsing to run make install only where an install target
exists. The prefix arm undid that, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 02384979bc fix(release): stop rejecting the changelog entry pkgmgr itself produced
A heading in the message became a bold line, and markdown-lint reads a bold
line on its own as a heading: MD036. Every structured entry therefore failed
the check it had just been normalised for, and since the loop reopens the
editor on failure there was no text that could end it.

Headings now move below the "## [version]" line the entry is filed under.
A "###" under a "##" is the hierarchy, not a competitor; only "#" and "##"
would have collided, and those are the ones raised.

The findings never reached the person either. They were printed and the
editor took the terminal over in the same breath, so the reason was gone
before it could be read. They are held on screen until Enter, and written
into the editor buffer as ';' lines above the rejected text, where the fix
is actually made; the buffer already drops those lines when it is read back.

transform_changelog_message and lint_changelog_entry were each tested alone,
which is how a transform that produces lint errors went unnoticed. A test now
runs the output of the first through the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
Kevin Veen-Birkenbach 59f470fb5e Merge pull request #5 from kevinveenbirkenbach/dependabot/github_actions/docker/login-action-4.6.0
build(deps): bump docker/login-action from 3.7.0 to 4.6.0
2026-10-01 16:28:28 +02:00
Kevin Veen-Birkenbach 1e5cc3f1aa Merge pull request #4 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/nix-installer-action-23
build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
2026-10-01 16:28:14 +02:00
Kevin Veen-Birkenbach fbe8018451 Merge pull request #3 from kevinveenbirkenbach/dependabot/github_actions/docker/setup-buildx-action-4.4.0
build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
2026-10-01 16:27:57 +02:00
Kevin Veen-Birkenbach 161d5d220b Merge pull request #2 from kevinveenbirkenbach/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 5 to 7
2026-10-01 16:27:32 +02:00
Kevin Veen-Birkenbach 0d4e33f4d3 Merge pull request #1 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/update-flake-lock-29
build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
2026-10-01 16:27:01 +02:00
dependabot[bot] 69dc430ca1 build(deps): bump docker/login-action from 3.7.0 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:26:19 +00:00
dependabot[bot] dd172c0c99 build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
Bumps [DeterminateSystems/nix-installer-action](https://github.com/determinatesystems/nix-installer-action) from 16 to 23.
- [Release notes](https://github.com/determinatesystems/nix-installer-action/releases)
- [Commits](https://github.com/determinatesystems/nix-installer-action/compare/v16...v23)

---
updated-dependencies:
- dependency-name: DeterminateSystems/nix-installer-action
  dependency-version: '23'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:10 +00:00
dependabot[bot] 42a7445865 build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.4.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...594f3bf4285d9ea8dc53c9a0c9c4092420091003)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:03 +00:00
dependabot[bot] 0ec74ad06f build(deps): bump actions/setup-python from 5 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:58 +00:00
dependabot[bot] fab5a5b6af build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
Bumps [DeterminateSystems/update-flake-lock](https://github.com/determinatesystems/update-flake-lock) from 24 to 29.
- [Release notes](https://github.com/determinatesystems/update-flake-lock/releases)
- [Commits](https://github.com/determinatesystems/update-flake-lock/compare/v24...v29)

---
updated-dependencies:
- dependency-name: DeterminateSystems/update-flake-lock
  dependency-version: '29'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:52 +00:00
47 changed files with 2176 additions and 116 deletions

No files matched your search

+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Run hadolint (produce SARIF) - name: Run hadolint (produce SARIF)
id: hadolint id: hadolint
continue-on-error: true continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
with: with:
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
format: sarif format: sarif
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v7
with: with:
python-version: "3.12" python-version: "3.12"
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
lint-shell: lint-shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Install ShellCheck - name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run ShellCheck - name: Run ShellCheck
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true # We need tags and main history for version comparison fetch-tags: true # We need tags and main history for version comparison
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Checkout repository (with tags) - name: Checkout repository (with tags)
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -38,13 +38,13 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: ${{ steps.info.outputs.should_publish == 'true' }} if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with: with:
use: true use: true
- name: Login to GHCR - name: Login to GHCR
if: ${{ steps.info.outputs.should_publish == 'true' }} if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show commit SHA - name: Show commit SHA
run: git rev-parse HEAD run: git rev-parse HEAD
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+3 -3
View File
@@ -18,13 +18,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Install Nix - name: Install Nix
uses: DeterminateSystems/nix-installer-action@v16 uses: DeterminateSystems/nix-installer-action@v23
- name: Update flake.lock and open a pull request - name: Update flake.lock and open a pull request
uses: DeterminateSystems/update-flake-lock@v24 uses: DeterminateSystems/update-flake-lock@v29
with: with:
pr-title: "chore(deps): update flake.lock" pr-title: "chore(deps): update flake.lock"
pr-labels: | pr-labels: |
+97
View File
@@ -1,5 +1,102 @@
# Changelog # Changelog
## [2.1.1] - 2026-10-31
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.0] - 2026-10-31
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.0.5] - 2026-10-29
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
## [2.0.4] - 2026-10-19
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [2.0.3] - 2026-10-18
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [2.0.2] - 2026-10-01
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
## [2.0.1] - 2026-09-18 ## [2.0.1] - 2026-09-18
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012. * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
+1 -1
View File
@@ -32,7 +32,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "2.0.1"; version = "2.1.1";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=2.0.1 pkgver=2.1.1
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')
+109
View File
@@ -1,3 +1,112 @@
package-manager (2.1.1-1) unstable; urgency=medium
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 18:47:35 +0100
package-manager (2.1.0-1) unstable; urgency=medium
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 14:38:54 +0100
package-manager (2.0.5-1) unstable; urgency=medium
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 29 Oct 2026 13:57:31 +0100
package-manager (2.0.4-1) unstable; urgency=medium
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 19 Oct 2026 10:06:02 +0200
package-manager (2.0.3-1) unstable; urgency=medium
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 18 Oct 2026 23:33:52 +0200
package-manager (2.0.2-1) unstable; urgency=medium
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 01 Oct 2026 17:47:12 +0200
package-manager (2.0.1-1) unstable; urgency=medium package-manager (2.0.1-1) unstable; urgency=medium
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012. * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
+92 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 2.0.1 Version: 2.1.1
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,97 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
- Fixed:
- - Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
- Upgrade notes:
- - Container: no 2.1.0 image reached GHCR; the two notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.0-1
- Added:
- - Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- - Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- - Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- - Release: Cargo.toml version and its Cargo.lock entry follow the release
- Changed:
- - Release: starts from the highest version in the tag and all version files
- Upgrade notes:
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
- Fixed:
- - Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- - Config: *pkgmgr config edit* exits with the editor's code when it fails
* Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
- Fixed:
- - CI: Python lint passes again on ruff 0.17 and still on 0.16
- - CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
- Upgrade notes:
- - 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* Sun Oct 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.3-1
- Fixed:
- - Release: the entry is linted inside the CHANGELOG.md it lands in
- - Release: a list marker that breaks the file's consistency is caught (MD004)
- - Release: findings the file already has are not blamed on the entry
- - Release: *--no-globs* keeps the target repo's other files out of the lint
- - Release: without markdownlint-cli2, list items must use dash bullets
- Upgrade notes:
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* Thu Oct 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.2-1
- Fixes a release flow that could reject every entry written for it.
- ### Fixed
- - A heading in a changelog message became bold, which markdown-lint reads
- back as a heading and refuses with MD036
- - Since the editor reopens on failure, no structured entry could ever pass
- - Headings now move below the release heading they are filed under
- - The lint findings reached nobody: the editor took over the terminal in the
- same breath they were printed
- - They are held until Enter and repeated inside the editor, above the text
- - The Makefile installer accepted install-* as proof of an install target but
- always ran plain make install
- - A repository with install-python and no install died on "No rule to make
- target" instead of being skipped
- ### Tests
- - transform and lint were covered separately, so a transform producing lint
- errors went unseen; one test now runs the first through the second
- - Three tests were checking behaviour the code had already left behind
- ### Dependencies
- - docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- - actions/setup-python 5 to 7
- - DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
- ### Upgrading
- - Nothing to do; a changelog entry written with headings now keeps them
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1 * Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1
- * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012. - * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
- * The entry's separator only belongs there when another entry follows it. - * The entry's separator only belongs there when another entry follows it.
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "2.0.1" version = "2.1.1"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+3
View File
@@ -4,9 +4,12 @@ Public API for branch actions.
from .close_branch import close_branch from .close_branch import close_branch
from .drop_branch import drop_branch from .drop_branch import drop_branch
from .gc import branch_gc, branch_purge
from .open_branch import open_branch from .open_branch import open_branch
__all__ = [ __all__ = [
"branch_gc",
"branch_purge",
"close_branch", "close_branch",
"drop_branch", "drop_branch",
"open_branch", "open_branch",
+707
View File
@@ -0,0 +1,707 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
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
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
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 _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)
return f"pr#{number}" if number else None
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.reason = f"merged by {item.merged_by}"
item.skip = _skip(repo, item, tree, checkout, cwds)
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 plan_repo(
repo_root: str, containers: list[Container] | None, host_ns: bool
) -> Repo:
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}")
repo = Repo(
repo=repo_root,
default=default,
remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected},
first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
),
forge=forge,
containers=containers,
host_ns=host_ns,
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.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,
"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]
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace()
containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns) 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],
) -> 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.
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
prepared = _prepare(repo_roots, include, exclude)
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]
) -> 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.
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
prepared = _prepare(repo_roots, include, exclude)
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
@@ -31,9 +31,7 @@ class MakefileInstaller(BaseInstaller):
except OSError: except OSError:
return False return False
if re.search(r"^install\s*:", content, flags=re.MULTILINE): return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE))
return True
return bool(re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE))
def run(self, ctx: RepoContext) -> None: def run(self, ctx: RepoContext) -> None:
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME) makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
@@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.:
from __future__ import annotations from __future__ import annotations
from .cargo import update_cargo_version
from .changelog_md import update_changelog from .changelog_md import update_changelog
from .debian import _get_debian_author, update_debian_changelog from .debian import _get_debian_author, update_debian_changelog
from .editor import _open_editor_for_changelog from .editor import _open_editor_for_changelog
@@ -22,6 +23,7 @@ from .rpm_spec import update_spec_version
__all__ = [ __all__ = [
"_get_debian_author", "_get_debian_author",
"_open_editor_for_changelog", "_open_editor_for_changelog",
"update_cargo_version",
"update_changelog", "update_changelog",
"update_debian_changelog", "update_debian_changelog",
"update_flake_version", "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,11 +6,15 @@ import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from collections import Counter
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$") _HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
_INLINE_CODE = re.compile(r"`([^`\n]+)`") _INLINE_CODE = re.compile(r"`([^`\n]+)`")
_FINDING = re.compile(r"\bMD\d{3}\b") _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,}") _MULTI_BLANK = re.compile(r"\n{3,}")
_ENTRY_LEVEL = 3
class ChangelogLintError(RuntimeError): class ChangelogLintError(RuntimeError):
@@ -20,10 +24,10 @@ class ChangelogLintError(RuntimeError):
def transform_changelog_message(text: str) -> str: def transform_changelog_message(text: str) -> str:
"""Normalise a free-form release message into the changelog house style. """Normalise a free-form release message into the changelog house style.
A leading ``#`` heading becomes a bold line of its own (markdown Headings are pushed below the ``## [version]`` line the entry is filed
headings inside an entry body would collide with the ``## [version]`` under, so they nest instead of competing with it. Bolding them instead
structure), and inline ``code`` spans become ``*italic*`` so the entry would read as a heading to markdown-lint and fail MD036. Inline ``code``
stays free of backticks. spans become ``*italic*`` so the entry stays free of backticks.
""" """
text = _INLINE_CODE.sub(r"*\1*", text) text = _INLINE_CODE.sub(r"*\1*", text)
@@ -31,9 +35,10 @@ def transform_changelog_message(text: str) -> str:
for line in text.split("\n"): for line in text.split("\n"):
heading = _HEADING.match(line) heading = _HEADING.match(line)
if heading: if heading:
level = max(len(heading.group(1)), _ENTRY_LEVEL)
if out and out[-1].strip(): if out and out[-1].strip():
out.append("") out.append("")
out.append(f"**{heading.group(1).strip()}**") out.append(f"{'#' * level} {heading.group(2).strip()}")
out.append("") out.append("")
else: else:
out.append(line) out.append(line)
@@ -57,6 +62,35 @@ def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
return _builtin_lint(document) 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]: def _markdownlint(reference_path: str, document: str) -> list[str]:
directory = os.path.dirname(os.path.abspath(reference_path)) or "." directory = os.path.dirname(os.path.abspath(reference_path)) or "."
fd, tmp_path = tempfile.mkstemp( fd, tmp_path = tempfile.mkstemp(
@@ -67,7 +101,7 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
with os.fdopen(fd, "w", encoding="utf-8") as handle: with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(document) handle.write(document)
proc = subprocess.run( proc = subprocess.run(
["markdownlint-cli2", name], ["markdownlint-cli2", "--no-globs", name],
cwd=directory, cwd=directory,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -94,6 +128,8 @@ def _builtin_lint(document: str) -> list[str]:
errors.append(f"line {number}: trailing whitespace (MD009)") errors.append(f"line {number}: trailing whitespace (MD009)")
if "`" in line: if "`" in line:
errors.append(f"line {number}: backtick is not allowed (use *italic*)") 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: if "\n\n\n" in document:
errors.append("multiple consecutive blank lines (MD012)") errors.append("multiple consecutive blank lines (MD012)")
return errors return errors
@@ -3,10 +3,11 @@ from __future__ import annotations
import os import os
import re import re
import sys import sys
from datetime import date from datetime import datetime
from .changelog_lint import ( from .changelog_lint import (
ChangelogLintError, ChangelogLintError,
lint_changelog_context,
lint_changelog_entry, lint_changelog_entry,
transform_changelog_message, transform_changelog_message,
) )
@@ -68,7 +69,7 @@ def update_changelog(
missing) and above any existing release entries, so the result stays missing) and above any existing release entries, so the result stays
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks). 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]: def _entry_for(raw: str) -> tuple[str, str]:
body = transform_changelog_message(raw).strip() or f"Release {new_version}" body = transform_changelog_message(raw).strip() or f"Release {new_version}"
@@ -80,30 +81,10 @@ def update_changelog(
print(f" - {finding}") print(f" - {finding}")
print() print()
if message is not None: def _wait_for_reader() -> None:
body, entry = _entry_for(message) try:
findings = lint_changelog_entry(changelog_path, entry) input("[INFO] Press Enter to re-open the editor and fix the entry...")
if findings: except EOFError:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
)
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
attempt: str | None = None
while True:
print(
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
"bold, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = lint_changelog_entry(changelog_path, entry)
if not findings:
break
_print_findings(findings)
attempt = body
print("[INFO] Re-opening the editor so you can fix the entry...") print("[INFO] Re-opening the editor so you can fix the entry...")
changelog = "" changelog = ""
@@ -114,6 +95,39 @@ def update_changelog(
except (OSError, UnicodeDecodeError) as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {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 = _findings_for(entry)
if findings:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
)
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
attempt: str | None = None
rejected: list[str] | None = None
while True:
print(
"\n[INFO] Provide the changelog entry - a leading '#' becomes "
"a sub-heading, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt, rejected)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = _findings_for(entry)
if not findings:
break
_print_findings(findings)
attempt = body
rejected = findings
_wait_for_reader()
new_changelog = _insert_after_h1(changelog, entry) new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================") print("\n================ CHANGELOG ENTRY ================")
+16 -3
View File
@@ -6,7 +6,14 @@ import subprocess
import tempfile import tempfile
def _open_editor_for_changelog(initial_message: str | None = None) -> str: def _open_editor_for_changelog(
initial_message: str | None = None,
findings: list[str] | None = None,
) -> str:
"""Args:
initial_message: the previous attempt, pre-loaded for editing.
findings: why that attempt was rejected, shown above it.
"""
editor = os.environ.get("EDITOR", "nano") editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
@@ -18,9 +25,15 @@ def _open_editor_for_changelog(initial_message: str | None = None) -> str:
tmp.write( tmp.write(
"; Write the changelog entry for this release.\n" "; Write the changelog entry for this release.\n"
"; Lines starting with ';' are ignored.\n" "; Lines starting with ';' are ignored.\n"
"; A leading '#' becomes bold; `code` becomes italic.\n" "; A leading '#' becomes a sub-heading; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n" "; Empty result will fall back to a generic message.\n"
) )
if findings:
tmp.write(";\n; markdown-lint rejected the previous entry:\n")
for finding in findings:
for line in str(finding).splitlines() or [""]:
tmp.write(f"; {line}\n")
tmp.write("\n")
if initial_message: if initial_message:
tmp.write(initial_message.strip() + "\n") tmp.write(initial_message.strip() + "\n")
tmp.flush() tmp.flush()
@@ -3,6 +3,11 @@ from __future__ import annotations
import os import os
import re import re
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore
def update_pyproject_version( def update_pyproject_version(
pyproject_path: str, new_version: str, preview: bool = False 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}") print(f"[WARN] Could not read pyproject.toml: {exc}")
return 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) m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
if not m: if not m:
raise RuntimeError("Missing [project] section in pyproject.toml") raise RuntimeError("Missing [project] section in pyproject.toml")
+49 -13
View File
@@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow.
from __future__ import annotations from __future__ import annotations
import re
from pkgmgr.core.git.queries import get_tags from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import ( from pkgmgr.core.version.semver import (
SemVer, SemVer,
@@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import (
bump_patch, bump_patch,
find_latest_version, 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: Args:
- If there are no tags or no SemVer-compatible tags, return 0.0.0. repo_dir: repository root holding the version files.
- Otherwise, use the latest SemVer tag as current version.
Returns:
The highest version found, or 0.0.0 when no source has one.
""" """
tags = get_tags() versions = _file_versions(repo_dir)
if not tags: latest = find_latest_version(get_tags() or [])
if latest is not None:
versions["tag"] = latest[1]
if not versions:
return SemVer(0, 0, 0) return SemVer(0, 0, 0)
latest = find_latest_version(tags) highest = max(versions.values())
if latest is None: if len(set(versions.values())) > 1:
return SemVer(0, 0, 0) found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
_tag, ver = latest return highest
return ver
def bump_semver(current: SemVer, release_type: str) -> SemVer: def bump_semver(current: SemVer, release_type: str) -> SemVer:
+6 -2
View File
@@ -10,6 +10,7 @@ from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import ( from .files import (
update_cargo_version,
update_changelog, update_changelog,
update_debian_changelog, update_debian_changelog,
update_flake_version, update_flake_version,
@@ -48,7 +49,8 @@ def _release_impl(
# Pull BEFORE making any modifications # Pull BEFORE making any modifications
ensure_clean_and_synced(preview=preview) 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 = bump_semver(current_ver, release_type)
new_ver_str = str(new_ver) new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True) new_tag = new_ver.to_tag(with_prefix=True)
@@ -58,7 +60,6 @@ def _release_impl(
print(f"Current version: {current_ver}") print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})") 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) paths = resolve_repo_paths(repo_root)
# --- Update versioned files ------------------------------------------------ # --- Update versioned files ------------------------------------------------
@@ -74,6 +75,8 @@ def _release_impl(
update_flake_version(paths.flake_nix, new_ver_str, preview=preview) 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: if paths.arch_pkgbuild:
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview) update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
else: else:
@@ -124,6 +127,7 @@ def _release_impl(
paths.arch_pkgbuild, paths.arch_pkgbuild,
paths.rpm_spec, paths.rpm_spec,
paths.debian_changelog, paths.debian_changelog,
*cargo_files,
] ]
existing_files = [ existing_files = [
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p) p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
+38 -1
View File
@@ -1,9 +1,26 @@
from __future__ import annotations from __future__ import annotations
import os
import sys 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.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: def handle_branch(args, ctx: CLIContext) -> None:
@@ -14,7 +31,27 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch open [<name>] [--base <branch>] - pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f] - pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f] - pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
""" """
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)
if args.subcommand == "gc":
code = branch_gc(
roots,
apply=args.apply,
as_json=args.json,
include=args.branch,
exclude=args.exclude,
)
else:
code = branch_purge(roots, include=args.branch, exclude=args.exclude)
sys.exit(code)
if args.subcommand == "open": if args.subcommand == "open":
open_branch( open_branch(
name=getattr(args, "name", None), name=getattr(args, "name", None),
+4 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any 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.init import config_init
from pkgmgr.actions.config.show import show_config from pkgmgr.actions.config.show import show_config
from pkgmgr.cli.context import CLIContext 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.config.save import save_user_config
from pkgmgr.core.repository.resolve import resolve_repos from pkgmgr.core.repository.resolve import resolve_repos
@@ -108,7 +108,9 @@ def handle_config(args, ctx: CLIContext) -> None:
return return
if args.subcommand == "edit": 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 return
if args.subcommand == "init": if args.subcommand == "init":
+44
View File
@@ -96,3 +96,47 @@ def add_branch_subparsers(
action="store_true", action="store_true",
help="Skip confirmation prompt and drop the branch directly", 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",
)
+37
View File
@@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
return None return 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
+322
View File
@@ -0,0 +1,322 @@
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]) -> 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)
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_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=[]
)
data = json.loads(out.getvalue())
self.assertEqual(code, 0)
self.assertEqual([i["branch"] for i in data["repos"][0]["items"]], ["merged"])
with patch.object(gc, "host_pid_namespace", return_value=False):
self.assertEqual(
gc.branch_gc(
[self.repo], apply=True, as_json=False, include=[], exclude=[]
),
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",
]
)
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"]
)
if __name__ == "__main__":
unittest.main()
@@ -72,6 +72,8 @@ class TestDebianControlInstaller(unittest.TestCase):
return "/usr/bin/dpkg-buildpackage" return "/usr/bin/dpkg-buildpackage"
if name == "apt-get": if name == "apt-get":
return "/usr/bin/apt-get" return "/usr/bin/apt-get"
if name == "sudo":
return "/usr/bin/sudo"
return None return None
mock_which.side_effect = which_side_effect mock_which.side_effect = which_side_effect
@@ -81,6 +81,19 @@ class TestMakefileInstaller(unittest.TestCase):
) )
mock_run_command.assert_not_called() mock_run_command.assert_not_called()
@patch("pkgmgr.actions.install.installers.makefile.run_command")
@patch(
"builtins.open",
new_callable=mock_open,
read_data="install-python:\n\tpip install -r requirements.txt\n",
)
@patch("os.path.exists", return_value=True)
def test_run_skips_a_makefile_that_only_has_a_prefixed_target(
self, mock_exists, mock_file, mock_run_command
):
self.installer.run(self.ctx)
mock_run_command.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.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,23 +1,31 @@
from __future__ import annotations from __future__ import annotations
import shutil
import unittest import unittest
from unittest.mock import patch
from pkgmgr.actions.release.files import changelog_lint
from pkgmgr.actions.release.files.changelog_lint import ( from pkgmgr.actions.release.files.changelog_lint import (
lint_changelog_context,
lint_changelog_entry, lint_changelog_entry,
transform_changelog_message, 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): class TestTransformChangelogMessage(unittest.TestCase):
def test_heading_becomes_bold(self) -> None: def test_heading_is_pushed_below_the_release_heading(self) -> None:
out = transform_changelog_message("# Title\n\nbody") out = transform_changelog_message("# Title\n\nbody")
self.assertIn("**Title**", out) self.assertIn("### Title", out)
self.assertNotIn("# Title", out) self.assertNotIn("**Title**", out)
def test_multi_hash_heading_becomes_bold(self) -> None: def test_a_heading_already_deep_enough_keeps_its_level(self) -> None:
self.assertEqual( self.assertEqual(transform_changelog_message("### Sub heading"), "### Sub heading")
transform_changelog_message("### Sub heading"), "**Sub heading**"
) def test_a_deeper_heading_is_left_alone(self) -> None:
self.assertEqual(transform_changelog_message("#### Deeper"), "#### Deeper")
def test_inline_code_becomes_italic(self) -> None: def test_inline_code_becomes_italic(self) -> None:
out = transform_changelog_message("use `pkgmgr release` now") out = transform_changelog_message("use `pkgmgr release` now")
@@ -27,18 +35,52 @@ class TestTransformChangelogMessage(unittest.TestCase):
def test_plain_message_is_unchanged(self) -> None: def test_plain_message_is_unchanged(self) -> None:
self.assertEqual(transform_changelog_message("just text"), "just text") self.assertEqual(transform_changelog_message("just text"), "just text")
def test_no_heading_or_backtick_survives(self) -> None: def test_no_backtick_survives_and_no_heading_competes(self) -> None:
out = transform_changelog_message("# Heading\n\n* item with `code`") out = transform_changelog_message("# Heading\n\n* item with `code`")
self.assertNotIn("`", out) self.assertNotIn("`", out)
for line in out.split("\n"): for line in out.split("\n"):
self.assertFalse(line.lstrip().startswith("#")) if line.lstrip().startswith("#"):
self.assertTrue(line.startswith("###"), line)
class TestLintChangelogEntry(unittest.TestCase): class TestLintChangelogEntry(unittest.TestCase):
def test_clean_entry_has_no_findings(self) -> None: 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), []) 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"
)
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 if __name__ == "__main__": # pragma: no cover
unittest.main() unittest.main()
@@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from unittest import mock
from pkgmgr.actions.release.files.editor import _open_editor_for_changelog
class TestOpenEditorForChangelog(unittest.TestCase):
def _buffer(self, **kwargs: object) -> str:
"""Returns: what the editor was handed, with the editor itself a no-op."""
seen: dict[str, str] = {}
def record(argv: list[str]) -> int:
with open(argv[1], encoding="utf-8") as handle:
seen["text"] = handle.read()
return 0
with mock.patch(
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=record
):
_open_editor_for_changelog(**kwargs)
return seen["text"]
def test_the_rejection_reaches_the_editor_the_entry_is_fixed_in(self) -> None:
text = self._buffer(
initial_message="### Security\n\n- a bullet",
findings=["changelog entry:3 error MD036/no-emphasis-as-heading"],
)
self.assertIn("MD036/no-emphasis-as-heading", text)
self.assertIn("### Security", text)
def test_every_finding_line_is_commented_out(self) -> None:
text = self._buffer(findings=["first line\nsecond line"])
for line in text.splitlines():
if "line" in line and "ignored" not in line:
self.assertTrue(line.startswith(";"), line)
def test_a_finding_never_lands_in_the_entry(self) -> None:
seen: dict[str, str] = {}
def rewrite(argv: list[str]) -> int:
with open(argv[1], encoding="utf-8") as handle:
seen["text"] = handle.read()
return 0
with mock.patch(
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=rewrite
):
kept = _open_editor_for_changelog(
initial_message="- a bullet", findings=["MD036 somewhere"]
)
self.assertEqual(kept, "- a bullet")
def test_without_findings_the_header_stays_as_it_was(self) -> None:
self.assertNotIn("markdown-lint rejected", self._buffer())
if __name__ == "__main__": # pragma: no cover
unittest.main()
+44 -12
View File
@@ -88,12 +88,15 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
self.assertIn("Missing version key", str(cm.exception)) 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 = ( original = (
textwrap.dedent( textwrap.dedent(
""" """
name = "example" [dependency-groups]
version = "0.1.0" lint = ["ruff==0.17.3"]
[tool.ruff]
line-length = 88
""" """
).strip() ).strip()
+ "\n" + "\n"
@@ -104,10 +107,38 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write(original) f.write(original)
with self.assertRaises(RuntimeError) as cm: update_pyproject_version(path, "1.2.3", preview=False)
update_pyproject_version(path, "1.2.3", preview=False)
self.assertIn("Missing [project] section", str(cm.exception)) with open(path, encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_update_pyproject_version_skips_dynamic_version(self) -> None:
original = (
textwrap.dedent(
"""
[project]
name = "example"
dynamic = [
"version",
]
"""
).strip()
+ "\n"
)
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pyproject_version(path, "1.2.3", preview=False)
with open(path, encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_update_pyproject_version_missing_file_is_skipped(self) -> None: def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: 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: def test_update_changelog_does_not_inject_a_leading_bullet(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md") 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: with open(path, encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertIn("\n\n* Provided bullet\n", content) self.assertIn("\n\n- Provided bullet\n", content)
self.assertNotIn("* * Provided bullet", content) self.assertNotIn("- - Provided bullet", content)
def test_update_changelog_transforms_heading_and_inline_code(self) -> None: def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -327,16 +358,17 @@ class TestUpdateChangelog(unittest.TestCase):
update_changelog( update_changelog(
path, path,
"1.2.3", "1.2.3",
message="# Summary\n\n* uses `foo` tool", message="# Summary\n\n- uses `foo` tool",
preview=False, preview=False,
) )
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertIn("**Summary**", content) self.assertIn("### Summary", content)
self.assertIn("*foo*", content) self.assertIn("*foo*", content)
self.assertNotIn("# Summary", content) self.assertNotIn("\n# Summary", content)
self.assertNotIn("\n## Summary", content)
self.assertNotIn("`foo`", content) self.assertNotIn("`foo`", content)
def test_update_changelog_preview_does_not_write(self) -> None: def test_update_changelog_preview_does_not_write(self) -> None:
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -11,12 +13,23 @@ from pkgmgr.core.version.semver import SemVer
class TestDetermineCurrentVersion(unittest.TestCase): 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=[]) @patch("pkgmgr.actions.release.versioning.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero( def test_determine_current_version_no_tags_returns_zero(
self, self,
mock_get_tags, mock_get_tags,
) -> None: ) -> None:
ver = determine_current_version() ver = determine_current_version(self.repo)
self.assertIsInstance(ver, SemVer) self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0)) self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once() 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_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 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)) self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once() mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"]) 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): class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None: 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()
@@ -29,22 +29,19 @@ class FindDefaultsSourceDirTests(unittest.TestCase):
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve()) self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
def test_falls_back_to_project_root_config(self): def test_a_config_dir_beside_the_package_is_not_taken(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
root = Path(td) root = Path(td)
pkg_root = root / "site-packages" / "pkgmgr" pkg_root = root / "site-packages" / "pkgmgr"
pkg_root.mkdir(parents=True) pkg_root.mkdir(parents=True)
# only project_root config exists
(pkg_root.parent / "config").mkdir(parents=True) (pkg_root.parent / "config").mkdir(parents=True)
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py")) fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}): with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
found = config_cmd._find_defaults_source_dir() found = config_cmd._find_defaults_source_dir()
self.assertEqual( self.assertIsNone(found)
Path(found).resolve(), (pkg_root.parent / "config").resolve()
)
def test_returns_none_when_no_config_dirs_exist(self): def test_returns_none_when_no_config_dirs_exist(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
@@ -88,9 +85,8 @@ class UpdateDefaultConfigsTests(unittest.TestCase):
self.assertTrue((dest_cfg_dir / "a.yaml").is_file()) self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
self.assertTrue((dest_cfg_dir / "b.yml").is_file()) self.assertTrue((dest_cfg_dir / "b.yml").is_file())
self.assertFalse( self.assertFalse(
(dest_cfg_dir / "config.yaml") (dest_cfg_dir / "config.yaml").exists(),
.read_text(encoding="utf-8") "the source config.yaml must not be copied over the user's own",
.startswith("should_not_copy")
) )
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it) # Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
+1 -3
View File
@@ -242,9 +242,7 @@ class LoadConfigIntegrationUnitTests(unittest.TestCase):
# Provide package defaults via fake pkgmgr + pkg_root/config # Provide package defaults via fake pkgmgr + pkg_root/config
root = Path(td) / "site-packages" root = Path(td) / "site-packages"
pkg_root = root / "pkgmgr" pkg_root = root / "pkgmgr"
cfg_dir = ( cfg_dir = pkg_root / "config"
root / "config"
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
pkg_root.mkdir(parents=True) pkg_root.mkdir(parents=True)
cfg_dir.mkdir(parents=True) cfg_dir.mkdir(parents=True)