Compare commits
16
Commits
1f6fe52855
..
main
No files matched your search
@@ -15,12 +15,12 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Run hadolint (produce SARIF)
|
- name: Run hadolint (produce SARIF)
|
||||||
id: hadolint
|
id: hadolint
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
|
||||||
with:
|
with:
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
format: sarif
|
format: sarif
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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@v7
|
uses: actions/setup-python@v7
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
lint-shell:
|
lint-shell:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Install ShellCheck
|
- name: Install ShellCheck
|
||||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
- name: Run ShellCheck
|
- name: Run ShellCheck
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true # We need tags and main history for version comparison
|
fetch-tags: true # We need tags and main history for version comparison
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v4
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show commit SHA
|
- name: Show commit SHA
|
||||||
run: git rev-parse HEAD
|
run: git rev-parse HEAD
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|||||||
@@ -19,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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@v23
|
uses: DeterminateSystems/nix-installer-action@v23
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
# 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
|
## [2.0.2] - 2026-10-01
|
||||||
|
|
||||||
Fixes a release flow that could reject every entry written for it.
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "2.0.2";
|
version = "2.1.1";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -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.2
|
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')
|
||||||
|
|||||||
@@ -1,3 +1,76 @@
|
|||||||
|
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
|
package-manager (2.0.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
Fixes a release flow that could reject every entry written for it.
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 2.0.2
|
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,64 @@ 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
|
* 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.
|
- Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "2.0.2"
|
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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from pkgmgr.actions.mirror.url_utils import parse_repo_from_git_url
|
||||||
|
from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver
|
||||||
|
from pkgmgr.core.credentials.types import CredentialError
|
||||||
|
from pkgmgr.core.remote_provisioning.http.client import HttpClient
|
||||||
|
from pkgmgr.core.remote_provisioning.http.errors import HttpError
|
||||||
|
|
||||||
|
GITHUB_HOST = "github.com"
|
||||||
|
GITHUB_API = "https://api.github.com"
|
||||||
|
PAGE_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForgeFacts:
|
||||||
|
"""
|
||||||
|
Pull-request and protection facts of every GitHub remote of one repository.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
merged_heads: head SHA of a merged PR -> PR number.
|
||||||
|
open_heads: head branch name of an open PR -> PR number.
|
||||||
|
protected: branch names protected on any of the remotes.
|
||||||
|
unavailable: why the facts could not be fetched, or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged_heads: dict[str, int] = field(default_factory=dict)
|
||||||
|
open_heads: dict[str, int] = field(default_factory=dict)
|
||||||
|
protected: set[str] = field(default_factory=set)
|
||||||
|
unavailable: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def github_slugs(remote_urls: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
slugs: list[tuple[str, str]] = []
|
||||||
|
for url in remote_urls.values():
|
||||||
|
host, owner, name = parse_repo_from_git_url(url)
|
||||||
|
if (
|
||||||
|
host.lower() == GITHUB_HOST
|
||||||
|
and owner
|
||||||
|
and name
|
||||||
|
and (owner, name) not in slugs
|
||||||
|
):
|
||||||
|
slugs.append((owner, name))
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def _pages(http: HttpClient, headers: dict[str, str], url: str) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
resp = http.request_json(
|
||||||
|
"GET", f"{url}&per_page={PAGE_SIZE}&page={page}", headers=headers
|
||||||
|
)
|
||||||
|
batch = json.loads(resp.text or "[]")
|
||||||
|
items.extend(batch)
|
||||||
|
if len(batch) < PAGE_SIZE:
|
||||||
|
return items
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
|
||||||
|
facts = ForgeFacts()
|
||||||
|
slugs = github_slugs(remote_urls)
|
||||||
|
if not slugs:
|
||||||
|
return facts
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = (
|
||||||
|
TokenResolver()
|
||||||
|
.get_token(
|
||||||
|
"github",
|
||||||
|
GITHUB_HOST,
|
||||||
|
options=ResolutionOptions(interactive=False, allow_prompt=False),
|
||||||
|
)
|
||||||
|
.token
|
||||||
|
)
|
||||||
|
except CredentialError as exc:
|
||||||
|
facts.unavailable = f"no GitHub token ({exc})"
|
||||||
|
return facts
|
||||||
|
|
||||||
|
http = HttpClient(timeout_s=30)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "pkgmgr",
|
||||||
|
}
|
||||||
|
for owner, name in slugs:
|
||||||
|
base = f"{GITHUB_API}/repos/{owner}/{name}"
|
||||||
|
try:
|
||||||
|
for pr in _pages(http, headers, f"{base}/pulls?state=all"):
|
||||||
|
if pr["merged_at"]:
|
||||||
|
facts.merged_heads[pr["head"]["sha"]] = pr["number"]
|
||||||
|
elif pr["state"] == "open":
|
||||||
|
facts.open_heads[pr["head"]["ref"]] = pr["number"]
|
||||||
|
for branch in _pages(http, headers, f"{base}/branches?protected=true"):
|
||||||
|
facts.protected.add(branch["name"])
|
||||||
|
except (HttpError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
facts.unavailable = f"GitHub API failed for {owner}/{name}: {exc}"
|
||||||
|
return facts
|
||||||
|
return facts
|
||||||
@@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __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",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: str) -> str:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: str, content: str) -> None:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def update_cargo_version(
|
||||||
|
repo_root: str, new_version: str, preview: bool = False
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Set the root crate's version in Cargo.toml and its entry in Cargo.lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: repository root that may hold Cargo.toml and Cargo.lock.
|
||||||
|
new_version: release version without the leading "v".
|
||||||
|
preview: only print what would change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The paths that were (or in preview would be) changed.
|
||||||
|
"""
|
||||||
|
toml_path = os.path.join(repo_root, "Cargo.toml")
|
||||||
|
if not os.path.exists(toml_path):
|
||||||
|
print("[INFO] No Cargo.toml found. Skipping Cargo version update.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
toml = _read(toml_path)
|
||||||
|
block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml)
|
||||||
|
if not block:
|
||||||
|
print("[INFO] Cargo.toml has no [package] section. Skipping.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0))
|
||||||
|
if not name:
|
||||||
|
raise RuntimeError("Missing name key in [package] section of Cargo.toml")
|
||||||
|
new_block, count = re.subn(
|
||||||
|
r'(?m)^(\s*version\s*=\s*")[^"]+(")',
|
||||||
|
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||||
|
block.group(0),
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
raise RuntimeError("Missing version key in [package] section of Cargo.toml")
|
||||||
|
changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]}
|
||||||
|
|
||||||
|
lock_path = os.path.join(repo_root, "Cargo.lock")
|
||||||
|
if os.path.exists(lock_path):
|
||||||
|
lock, count = re.subn(
|
||||||
|
rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n'
|
||||||
|
r'version = ")[^"]+("\n)(?!source = )',
|
||||||
|
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
|
||||||
|
_read(lock_path),
|
||||||
|
)
|
||||||
|
if count != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected one local {name.group(1)!r} package in Cargo.lock, "
|
||||||
|
f"found {count}"
|
||||||
|
)
|
||||||
|
changes[lock_path] = lock
|
||||||
|
|
||||||
|
for path, content in changes.items():
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Would update {path} version to {new_version}")
|
||||||
|
else:
|
||||||
|
_write(path, content)
|
||||||
|
print(f"Updated {path} version to {new_version}")
|
||||||
|
return list(changes)
|
||||||
@@ -6,10 +6,13 @@ import re
|
|||||||
import shutil
|
import 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
|
_ENTRY_LEVEL = 3
|
||||||
|
|
||||||
@@ -59,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(
|
||||||
@@ -69,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,
|
||||||
@@ -96,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}"
|
||||||
@@ -86,9 +87,22 @@ def update_changelog(
|
|||||||
except EOFError:
|
except EOFError:
|
||||||
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 = ""
|
||||||
|
if os.path.exists(changelog_path):
|
||||||
|
try:
|
||||||
|
with open(changelog_path, encoding="utf-8") as f:
|
||||||
|
changelog = f.read()
|
||||||
|
except (OSError, UnicodeDecodeError) as exc:
|
||||||
|
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||||
|
|
||||||
|
def _findings_for(entry: str) -> list[str]:
|
||||||
|
return lint_changelog_entry(changelog_path, entry) or lint_changelog_context(
|
||||||
|
changelog_path, changelog, _insert_after_h1(changelog, entry)
|
||||||
|
)
|
||||||
|
|
||||||
if message is not None:
|
if message is not None:
|
||||||
body, entry = _entry_for(message)
|
body, entry = _entry_for(message)
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = _findings_for(entry)
|
||||||
if findings:
|
if findings:
|
||||||
_print_findings(findings)
|
_print_findings(findings)
|
||||||
raise ChangelogLintError(
|
raise ChangelogLintError(
|
||||||
@@ -106,7 +120,7 @@ def update_changelog(
|
|||||||
)
|
)
|
||||||
raw = _open_editor_for_changelog(attempt, rejected)
|
raw = _open_editor_for_changelog(attempt, rejected)
|
||||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = _findings_for(entry)
|
||||||
if not findings:
|
if not findings:
|
||||||
break
|
break
|
||||||
_print_findings(findings)
|
_print_findings(findings)
|
||||||
@@ -114,14 +128,6 @@ def update_changelog(
|
|||||||
rejected = findings
|
rejected = findings
|
||||||
_wait_for_reader()
|
_wait_for_reader()
|
||||||
|
|
||||||
changelog = ""
|
|
||||||
if os.path.exists(changelog_path):
|
|
||||||
try:
|
|
||||||
with open(changelog_path, encoding="utf-8") as f:
|
|
||||||
changelog = f.read()
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
|
||||||
|
|
||||||
new_changelog = _insert_after_h1(changelog, entry)
|
new_changelog = _insert_after_h1(changelog, entry)
|
||||||
|
|
||||||
print("\n================ CHANGELOG ENTRY ================")
|
print("\n================ CHANGELOG ENTRY ================")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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":
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.files import update_cargo_version
|
||||||
|
|
||||||
|
CARGO_TOML = """[package]
|
||||||
|
name = "security-check"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0.0" }
|
||||||
|
"""
|
||||||
|
|
||||||
|
CARGO_LOCK = """version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-check"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "abc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-check"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateCargoVersion(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = self._tmp.name
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _write(self, name: str, content: str) -> str:
|
||||||
|
path = os.path.join(self.root, name)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _read(self, name: str) -> str:
|
||||||
|
with open(os.path.join(self.root, name), encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_bumps_package_and_local_lock_entry_only(self) -> None:
|
||||||
|
toml = self._write("Cargo.toml", CARGO_TOML)
|
||||||
|
lock = self._write("Cargo.lock", CARGO_LOCK)
|
||||||
|
|
||||||
|
changed = update_cargo_version(self.root, "0.4.0")
|
||||||
|
|
||||||
|
self.assertEqual(changed, [toml, lock])
|
||||||
|
self.assertEqual(
|
||||||
|
self._read("Cargo.toml"),
|
||||||
|
CARGO_TOML.replace('version = "0.1.0"', 'version = "0.4.0"'),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read("Cargo.lock"),
|
||||||
|
CARGO_LOCK.replace(
|
||||||
|
'name = "security-check"\nversion = "0.1.0"\ndependencies',
|
||||||
|
'name = "security-check"\nversion = "0.4.0"\ndependencies',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_without_cargo_toml_changes_nothing(self) -> None:
|
||||||
|
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [])
|
||||||
|
self.assertEqual(os.listdir(self.root), [])
|
||||||
|
|
||||||
|
def test_workspace_without_package_is_untouched(self) -> None:
|
||||||
|
workspace = '[workspace]\nmembers = ["a"]\n'
|
||||||
|
self._write("Cargo.toml", workspace)
|
||||||
|
|
||||||
|
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [])
|
||||||
|
self.assertEqual(self._read("Cargo.toml"), workspace)
|
||||||
|
|
||||||
|
def test_without_lock_bumps_only_toml(self) -> None:
|
||||||
|
toml = self._write("Cargo.toml", CARGO_TOML)
|
||||||
|
|
||||||
|
self.assertEqual(update_cargo_version(self.root, "0.4.0"), [toml])
|
||||||
|
|
||||||
|
def test_preview_writes_nothing(self) -> None:
|
||||||
|
self._write("Cargo.toml", CARGO_TOML)
|
||||||
|
self._write("Cargo.lock", CARGO_LOCK)
|
||||||
|
|
||||||
|
update_cargo_version(self.root, "0.4.0", preview=True)
|
||||||
|
|
||||||
|
self.assertEqual(self._read("Cargo.toml"), CARGO_TOML)
|
||||||
|
self.assertEqual(self._read("Cargo.lock"), CARGO_LOCK)
|
||||||
|
|
||||||
|
def test_lock_without_local_entry_fails_before_writing(self) -> None:
|
||||||
|
self._write("Cargo.toml", CARGO_TOML)
|
||||||
|
self._write("Cargo.lock", 'version = 4\n\n[[package]]\nname = "serde"\n')
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
update_cargo_version(self.root, "0.4.0")
|
||||||
|
self.assertEqual(self._read("Cargo.toml"), CARGO_TOML)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
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):
|
||||||
@@ -38,16 +45,42 @@ class TestTransformChangelogMessage(unittest.TestCase):
|
|||||||
|
|
||||||
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:
|
def test_a_structured_message_survives_its_own_transform(self) -> None:
|
||||||
body = transform_changelog_message(
|
body = transform_changelog_message(
|
||||||
"# Security\n\n* a bullet\n\n## Fixed\n\n* another bullet"
|
"# Security\n\n- a bullet\n\n## Fixed\n\n- another bullet"
|
||||||
)
|
)
|
||||||
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
|
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
|
||||||
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
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()
|
||||||
@@ -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,7 +358,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user