Compare commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:45 +01:00
21 changed files with 1650 additions and 24 deletions

No files matched your search

+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## [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 ## [2.0.4] - 2026-10-19
Fixed: Fixed:
+1 -1
View File
@@ -32,7 +32,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "2.0.4"; version = "2.1.0";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=2.0.4 pkgver=2.1.0
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')
+29
View File
@@ -1,3 +1,32 @@
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 package-manager (2.0.4-1) unstable; urgency=medium
Fixed: Fixed:
+24 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 2.0.4 Version: 2.1.0
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,29 @@ 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.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 * Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
- Fixed: - Fixed:
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "2.0.4" version = "2.1.0"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+3
View File
@@ -4,9 +4,12 @@ Public API for branch actions.
from .close_branch import close_branch from .close_branch import close_branch
from .drop_branch import drop_branch from .drop_branch import drop_branch
from .gc import branch_gc, branch_purge
from .open_branch import open_branch from .open_branch import open_branch
__all__ = [ __all__ = [
"branch_gc",
"branch_purge",
"close_branch", "close_branch",
"drop_branch", "drop_branch",
"open_branch", "open_branch",
+707
View File
@@ -0,0 +1,707 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from pkgmgr.core.git.commands import delete_local_branch
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import list_remotes, resolve_base_branch
from pkgmgr.core.git.run import run
from .gc_forge import ForgeFacts, fetch_forge_facts
# PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
INIT_PID_NAMESPACE = "pid:[4026531836]"
CLEANUP_IMAGE = "alpine:3.20"
COMPOSE_PROJECT = "com.docker.compose.project"
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
LOCK_PID = re.compile(r"\bpid (\d+)")
ALWAYS_PROTECTED = ("main", "master")
LABELS = {
"merged-clean": "DELETE",
"unmerged": "KEEP",
"dirty": "KEEP",
"protected": "PROTECT",
"stale": "STALE",
}
REFUSAL = (
"Refusing: not in the host PID namespace (e.g. a sandbox), so running "
"processes and worktree locks cannot be verified. Run it on the host."
)
@dataclass
class Worktree:
path: str
head: str | None
branch: str | None
locked: str | None
prunable: bool
main: bool
@dataclass
class Container:
project: str
working_dir: str
image: str
@dataclass
class Item:
branch: str | None
worktree: str | None = None
local_sha: str | None = None
remotes: dict[str, str] = field(default_factory=dict)
compose_projects: list[str] = field(default_factory=list)
images: list[str] = field(default_factory=list)
state: str = ""
merged_by: str | None = None
skip: str | None = None
actions: list[str] = field(default_factory=list)
reason: str = ""
result: str | None = None
@property
def eligible(self) -> bool:
return self.state == "merged-clean" and self.skip is None and bool(self.actions)
def matches(self, name: str) -> bool:
return name == self.branch or (
self.worktree is not None and os.path.abspath(name) == self.worktree
)
@dataclass
class Repo:
repo: str
default: str
remotes: list[str]
protected: set[str]
first_parent: set[str]
forge: ForgeFacts
containers: list[Container] | None
host_ns: bool
warnings: list[str] = field(default_factory=list)
items: list[Item] = field(default_factory=list)
def host_pid_namespace() -> bool:
try:
return os.readlink("/proc/self/ns/pid") == INIT_PID_NAMESPACE
except OSError:
return False
def main_repo_root(cwd: str) -> str:
return os.path.dirname(_common_dir(cwd))
def _common_dir(cwd: str) -> str:
return run(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd)
def _docker(args: list[str]) -> str:
return subprocess.run(
["docker", *args], check=True, capture_output=True, text=True
).stdout
def _describe(exc: Exception) -> str:
if isinstance(exc, subprocess.CalledProcessError):
return f"{' '.join(exc.cmd)}: {(exc.stderr or '').strip()}"
return str(exc).strip().splitlines()[0]
def list_compose_containers() -> list[Container] | None:
fmt = (
f'{{{{.Label "{COMPOSE_PROJECT}"}}}}\t'
f'{{{{.Label "{COMPOSE_WORKING_DIR}"}}}}\t{{{{.Image}}}}'
)
try:
out = _docker(
["ps", "-a", "--filter", f"label={COMPOSE_WORKING_DIR}", "--format", fmt]
)
except FileNotFoundError:
return []
except subprocess.CalledProcessError:
return None
return [
Container(*line.split("\t"))
for line in out.splitlines()
if line.count("\t") == 2
]
def _inside(path: str, root: str) -> bool:
return path == root or path.startswith(root.rstrip("/") + "/")
def _worktrees(repo_root: str) -> list[Worktree]:
trees: list[Worktree] = []
for block in run(["worktree", "list", "--porcelain"], cwd=repo_root).split("\n\n"):
fields: dict[str, str] = {}
for line in block.splitlines():
key, _, value = line.partition(" ")
fields[key] = value
if "worktree" not in fields:
continue
branch = fields.get("branch")
trees.append(
Worktree(
path=fields["worktree"],
head=fields.get("HEAD"),
branch=branch.removeprefix("refs/heads/") if branch else None,
locked=fields.get("locked"),
prunable="prunable" in fields,
main=not trees,
)
)
return trees
def _refs(
repo_root: str, remotes: list[str]
) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
out = run(
[
"for-each-ref",
"--format=%(refname) %(objectname)",
"refs/heads",
"refs/remotes",
],
cwd=repo_root,
)
by_length = sorted(remotes, key=len, reverse=True)
local: dict[str, str] = {}
remote: dict[str, dict[str, str]] = {}
for line in out.splitlines():
ref, sha = line.split(" ")
if ref.startswith("refs/heads/"):
local[ref.removeprefix("refs/heads/")] = sha
continue
rest = ref.removeprefix("refs/remotes/")
owner = next((r for r in by_length if rest.startswith(r + "/")), None)
if owner and rest[len(owner) + 1 :] != "HEAD":
remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha
return local, remote
def _process_cwds() -> dict[int, str]:
cwds: dict[int, str] = {}
for entry in os.listdir("/proc"):
if entry.isdigit() and int(entry) != os.getpid():
try:
cwds[int(entry)] = os.readlink(f"/proc/{entry}/cwd")
except OSError:
continue
return cwds
def _comm(pid: int) -> str:
try:
with open(f"/proc/{pid}/comm", encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return "?"
def _lock_skip(reason: str | None, host_ns: bool) -> str | None:
if reason is None:
return None
match = LOCK_PID.search(reason)
if not match:
return f"lock: {reason or 'without reason'}"
if not host_ns:
return "lock: liveness unknown"
pid = int(match.group(1))
return f"lock: pid {pid} alive" if os.path.exists(f"/proc/{pid}") else None
def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
try:
run(["merge-base", "--is-ancestor", sha, ref], cwd=repo_root)
except GitRunError as exc:
if exc.returncode == 1:
return False
raise
return True
def _merged_by(repo: Repo, sha: str) -> str | None:
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
return "ancestor"
number = repo.forge.merged_heads.get(sha)
return f"pr#{number}" if number else None
def _skip(
repo: Repo,
item: Item,
tree: Worktree | None,
checkout: bool,
cwds: dict[int, str],
) -> str | None:
number = repo.forge.open_heads.get(item.branch) if item.branch else None
if number:
return f"open-pr #{number}"
if repo.forge.unavailable:
return "forge unavailable"
if tree is None:
return None
if tree.main:
return "checked out in main worktree"
if not checkout:
return "worktree checkout missing"
if repo.containers is None:
return "docker unavailable"
lock = _lock_skip(tree.locked, repo.host_ns)
if lock:
return lock
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, tree.path))
if busy:
return f"process: pid {busy[0]} ({_comm(busy[0])})"
return None
def _build(
repo: Repo,
branch: str | None,
tree: Worktree | None,
snapshot: tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]],
) -> Item:
local, remote, cwds = snapshot
item = Item(
branch=branch,
worktree=tree.path if tree else None,
local_sha=local.get(branch) if branch else None,
remotes=dict(remote.get(branch, {})) if branch else {},
)
if branch in repo.protected:
item.state, item.reason = "protected", "default or protected branch"
return item
if tree and repo.containers:
mine = [c for c in repo.containers if _inside(c.working_dir, tree.path)]
item.compose_projects = sorted({c.project for c in mine})
item.images = sorted({c.image for c in mine})
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None]
verdicts = {sha: _merged_by(repo, sha) for sha in dict.fromkeys(shas) if sha}
unmerged = [sha for sha, verdict in verdicts.items() if verdict is None]
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
try:
dirty = (
len(
run(
["status", "--porcelain", "--untracked-files=all"], cwd=tree.path
).splitlines()
)
if checkout and not tree.main
else 0
)
except GitRunError as exc:
item.state, item.reason = "dirty", f"status failed: {_describe(exc)}"
dirty = -1
if dirty > 0:
item.state, item.reason = "dirty", f"dirty: {dirty} paths"
elif dirty == 0 and unmerged:
ahead = int(
run(
["rev-list", "--count", f"{repo.default}..{unmerged[0]}"], cwd=repo.repo
)
)
item.state = "unmerged"
item.reason = (
f"unmerged: {ahead} commits not in {repo.default}"
if ahead
else "no own commits"
)
elif dirty == 0:
item.state = "merged-clean"
item.merged_by = next(
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
"ancestor",
)
item.reason = f"merged by {item.merged_by}"
item.skip = _skip(repo, item, tree, checkout, cwds)
for project in item.compose_projects:
item.actions += [f"compose-down:{project}", f"images:{project}"]
if tree:
item.actions.append("worktree")
if item.local_sha:
item.actions.append("local-branch")
item.actions += [f"remote-branch:{name}" for name in item.remotes]
return item
def _snapshot(
repo: Repo,
) -> tuple[
list[Worktree], tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]]
]:
local, remote = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {}
return _worktrees(repo.repo), (local, remote, cwds)
def _items(repo: Repo) -> list[Item]:
trees, snapshot = _snapshot(repo)
local, remote, _ = snapshot
by_branch = {t.branch: t for t in trees if t.branch}
names = sorted(set(local) | set(remote) | set(by_branch))
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
items += [
_build(repo, None, tree, snapshot)
for tree in trees
if tree.branch is None and not tree.main
]
return items + _stale(repo.repo)
def _stale(repo_root: str) -> list[Item]:
base = os.path.join(_common_dir(repo_root), "worktrees")
items: list[Item] = []
for entry in sorted(os.listdir(base)) if os.path.isdir(base) else []:
meta = os.path.join(base, entry)
gitdir = os.path.join(meta, "gitdir")
if not os.path.isfile(gitdir) or os.path.getsize(gitdir) == 0:
reason = "metadata without gitdir, not pruned"
else:
with open(gitdir, encoding="utf-8") as fh:
target = fh.read().strip()
if os.path.exists(target):
continue
reason = f"checkout {os.path.dirname(target)} missing, not pruned"
items.append(Item(branch=None, worktree=meta, state="stale", reason=reason))
return items
def plan_repo(
repo_root: str, containers: list[Container] | None, host_ns: bool
) -> Repo:
warnings: list[str] = []
try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of
# sandboxed worktrees whose gitdir stub is missing.
run(
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
cwd=repo_root,
)
except GitRunError as exc:
warnings.append(f"fetch failed: {_describe(exc)}")
if containers is None:
warnings.append("docker unavailable: worktree items are skipped")
remotes = sorted(list_remotes(cwd=repo_root), key=lambda r: (r != "origin", r))
heads: dict[str, str] = {}
for remote in remotes:
try:
heads[remote] = run(
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"],
cwd=repo_root,
)
except GitRunError:
continue
default = next(iter(heads.values()), None) or resolve_base_branch(
"main", "master", cwd=repo_root
)
head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
forge = fetch_forge_facts(
{r: run(["remote", "get-url", r], cwd=repo_root) for r in remotes}
)
if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}")
repo = Repo(
repo=repo_root,
default=default,
remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected},
first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
),
forge=forge,
containers=containers,
host_ns=host_ns,
warnings=warnings,
)
repo.items = _items(repo)
return repo
def _foreign_owned(path: str) -> bool:
uid = os.getuid()
if os.lstat(path).st_uid != uid:
return True
for root, dirs, files in os.walk(path):
for name in (*dirs, *files):
if os.lstat(os.path.join(root, name)).st_uid != uid:
return True
return False
def _remove_worktree(repo_root: str, path: str) -> None:
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
meta = os.path.normpath(
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
)
if os.path.dirname(meta) != os.path.join(_common_dir(repo_root), "worktrees"):
raise RuntimeError(f"unexpected worktree metadata path {meta}")
if _foreign_owned(path):
parent, name = os.path.split(path.rstrip("/"))
_docker(
[
"run",
"--rm",
"-v",
f"{parent}:/w",
CLEANUP_IMAGE,
"rm",
"-rf",
f"/w/{name}",
]
)
else:
shutil.rmtree(path)
if os.path.lexists(path):
raise RuntimeError(f"worktree {path} still exists; metadata {meta} kept")
shutil.rmtree(meta)
def _rebuild(repo: Repo, item: Item) -> Item:
trees, snapshot = _snapshot(repo)
tree = next(
(
t
for t in trees
if (t.branch == item.branch if item.branch else t.path == item.worktree)
),
None,
)
return _build(repo, item.branch, tree, snapshot)
def _fingerprint(item: Item) -> tuple:
return (
item.state,
item.skip,
item.local_sha,
item.remotes,
item.worktree,
item.compose_projects,
)
def execute(repo: Repo, item: Item) -> None:
fresh = _rebuild(repo, item)
if _fingerprint(fresh) != _fingerprint(item):
item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})"
return
notes: list[str] = []
try:
for project in item.compose_projects:
_docker(["compose", "-p", project, "down", "-v", "--remove-orphans"])
for image in item.images:
try:
_docker(["image", "rm", image])
except subprocess.CalledProcessError:
notes.append(f"image kept: {image}")
if item.worktree:
_remove_worktree(repo.repo, item.worktree)
if item.local_sha:
delete_local_branch(item.branch, cwd=repo.repo, force=True)
for remote, sha in item.remotes.items():
run(
[
"push",
f"--force-with-lease=refs/heads/{item.branch}:{sha}",
remote,
f":refs/heads/{item.branch}",
],
cwd=repo.repo,
)
except (subprocess.CalledProcessError, RuntimeError, OSError) as exc:
item.result = f"failed: {_describe(exc)}"
return
item.result = "; ".join(["done", *notes])
def _line(item: Item) -> str:
label = "SKIP" if item.state == "merged-clean" and item.skip else LABELS[item.state]
parts = [item.reason]
if item.skip and item.state != "protected":
parts.append(item.skip)
if item.worktree and item.state != "stale":
parts.append(f"worktree {item.worktree}")
if item.compose_projects:
parts.append("stack " + ", ".join(item.compose_projects))
if item.remotes:
parts.append("remotes " + ", ".join(item.remotes))
line = f"{label:<8} {item.branch or item.worktree} {'; '.join(parts)}"
return f"{line} -> {item.result}" if item.result else line
def _render(
repos: list[Repo], host_ns: bool, chosen: Callable[[Item], bool], as_json: bool
) -> None:
if as_json:
print(
json.dumps(
{
"host_pid_namespace": host_ns,
"repos": [
{
"repo": repo.repo,
"default": repo.default,
"warnings": repo.warnings,
"items": [asdict(i) for i in repo.items if chosen(i)],
}
for repo in repos
],
},
indent=2,
)
)
return
for repo in repos:
print(f"== {repo.repo} (default {repo.default})")
for warning in repo.warnings:
print(f"WARN {warning}")
for item in repo.items:
if chosen(item):
print(_line(item))
if not host_ns:
print(
"WARN process and lock checks unavailable: "
"not in the host PID namespace"
)
def _prepare(
repo_roots: list[str], include: list[str], exclude: list[str]
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace()
containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns) for root in repo_roots]
unknown = [
name
for name in include
if not any(item.matches(name) for repo in repos for item in repo.items)
]
if unknown:
print(f"Unknown --branch: {', '.join(unknown)}", file=sys.stderr)
return None
def chosen(item: Item) -> bool:
return (not include or any(item.matches(n) for n in include)) and not any(
item.matches(n) for n in exclude
)
return repos, host_ns, chosen
def _finish(repos: list[Repo]) -> int:
results = [item.result for repo in repos for item in repo.items if item.result]
if any(result.startswith("done") for result in results) and any(
repo.containers is not None for repo in repos
):
for args in (["image", "prune", "-f"], ["builder", "prune", "-f"]):
try:
_docker(args)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
print(f"WARN {_describe(exc)}", file=sys.stderr)
return 1 if any(result.startswith("failed") for result in results) else 0
def branch_gc(
repo_roots: list[str],
*,
apply: bool,
as_json: bool,
include: list[str],
exclude: list[str],
) -> int:
"""
Plan, and with apply=True execute, the cleanup of merged and clean branches.
Args:
repo_roots: main worktree roots of the repositories to scan.
apply: execute the plan instead of only printing it.
as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
Returns:
0 on success, 1 when an applied item failed, 2 when refused.
"""
if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
if prepared is None:
return 2
repos, host_ns, chosen = prepared
if apply:
for repo in repos:
for item in repo.items:
if item.eligible and chosen(item):
execute(repo, item)
print(
f"[gc] {item.branch or item.worktree}: {item.result}",
file=sys.stderr,
)
_render(repos, host_ns, chosen, as_json)
return _finish(repos) if apply else 0
def branch_purge(
repo_roots: list[str], *, include: list[str], exclude: list[str]
) -> int:
"""
Delete unmerged or dirty items after a y/N confirmation per item.
Args:
repo_roots: main worktree roots of the repositories to scan.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
Returns:
0 on success, 1 when a confirmed item failed, 2 when refused.
"""
if not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
if prepared is None:
return 2
repos, _, chosen = prepared
for repo in repos:
for item in repo.items:
if item.state not in ("unmerged", "dirty") or item.skip or not chosen(item):
continue
print(_line(item))
print(f" removes: {', '.join(item.actions)}")
try:
answer = input("Delete this item? This is destructive! (y/N): ")
except EOFError:
answer = ""
if answer.strip().lower() != "y":
continue
execute(repo, item)
print(f" -> {item.result}")
return _finish(repos)
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pkgmgr.actions.mirror.url_utils import parse_repo_from_git_url
from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver
from pkgmgr.core.credentials.types import CredentialError
from pkgmgr.core.remote_provisioning.http.client import HttpClient
from pkgmgr.core.remote_provisioning.http.errors import HttpError
GITHUB_HOST = "github.com"
GITHUB_API = "https://api.github.com"
PAGE_SIZE = 100
@dataclass
class ForgeFacts:
"""
Pull-request and protection facts of every GitHub remote of one repository.
Attributes:
merged_heads: head SHA of a merged PR -> PR number.
open_heads: head branch name of an open PR -> PR number.
protected: branch names protected on any of the remotes.
unavailable: why the facts could not be fetched, or None.
"""
merged_heads: dict[str, int] = field(default_factory=dict)
open_heads: dict[str, int] = field(default_factory=dict)
protected: set[str] = field(default_factory=set)
unavailable: str | None = None
def github_slugs(remote_urls: dict[str, str]) -> list[tuple[str, str]]:
slugs: list[tuple[str, str]] = []
for url in remote_urls.values():
host, owner, name = parse_repo_from_git_url(url)
if (
host.lower() == GITHUB_HOST
and owner
and name
and (owner, name) not in slugs
):
slugs.append((owner, name))
return slugs
def _pages(http: HttpClient, headers: dict[str, str], url: str) -> list[dict]:
items: list[dict] = []
page = 1
while True:
resp = http.request_json(
"GET", f"{url}&per_page={PAGE_SIZE}&page={page}", headers=headers
)
batch = json.loads(resp.text or "[]")
items.extend(batch)
if len(batch) < PAGE_SIZE:
return items
page += 1
def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
facts = ForgeFacts()
slugs = github_slugs(remote_urls)
if not slugs:
return facts
try:
token = (
TokenResolver()
.get_token(
"github",
GITHUB_HOST,
options=ResolutionOptions(interactive=False, allow_prompt=False),
)
.token
)
except CredentialError as exc:
facts.unavailable = f"no GitHub token ({exc})"
return facts
http = HttpClient(timeout_s=30)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "pkgmgr",
}
for owner, name in slugs:
base = f"{GITHUB_API}/repos/{owner}/{name}"
try:
for pr in _pages(http, headers, f"{base}/pulls?state=all"):
if pr["merged_at"]:
facts.merged_heads[pr["head"]["sha"]] = pr["number"]
elif pr["state"] == "open":
facts.open_heads[pr["head"]["ref"]] = pr["number"]
for branch in _pages(http, headers, f"{base}/branches?protected=true"):
facts.protected.add(branch["name"])
except (HttpError, ValueError, KeyError, TypeError) as exc:
facts.unavailable = f"GitHub API failed for {owner}/{name}: {exc}"
return facts
return facts
@@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.:
from __future__ import annotations from __future__ import annotations
from .cargo import update_cargo_version
from .changelog_md import update_changelog from .changelog_md import update_changelog
from .debian import _get_debian_author, update_debian_changelog from .debian import _get_debian_author, update_debian_changelog
from .editor import _open_editor_for_changelog from .editor import _open_editor_for_changelog
@@ -22,6 +23,7 @@ from .rpm_spec import update_spec_version
__all__ = [ __all__ = [
"_get_debian_author", "_get_debian_author",
"_open_editor_for_changelog", "_open_editor_for_changelog",
"update_cargo_version",
"update_changelog", "update_changelog",
"update_debian_changelog", "update_debian_changelog",
"update_flake_version", "update_flake_version",
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import os
import re
def _read(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.read()
def _write(path: str, content: str) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def update_cargo_version(
repo_root: str, new_version: str, preview: bool = False
) -> list[str]:
"""
Set the root crate's version in Cargo.toml and its entry in Cargo.lock.
Args:
repo_root: repository root that may hold Cargo.toml and Cargo.lock.
new_version: release version without the leading "v".
preview: only print what would change.
Returns:
The paths that were (or in preview would be) changed.
"""
toml_path = os.path.join(repo_root, "Cargo.toml")
if not os.path.exists(toml_path):
print("[INFO] No Cargo.toml found. Skipping Cargo version update.")
return []
toml = _read(toml_path)
block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml)
if not block:
print("[INFO] Cargo.toml has no [package] section. Skipping.")
return []
name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0))
if not name:
raise RuntimeError("Missing name key in [package] section of Cargo.toml")
new_block, count = re.subn(
r'(?m)^(\s*version\s*=\s*")[^"]+(")',
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
block.group(0),
count=1,
)
if count == 0:
raise RuntimeError("Missing version key in [package] section of Cargo.toml")
changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]}
lock_path = os.path.join(repo_root, "Cargo.lock")
if os.path.exists(lock_path):
lock, count = re.subn(
rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n'
r'version = ")[^"]+("\n)(?!source = )',
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
_read(lock_path),
)
if count != 1:
raise RuntimeError(
f"Expected one local {name.group(1)!r} package in Cargo.lock, "
f"found {count}"
)
changes[lock_path] = lock
for path, content in changes.items():
if preview:
print(f"[PREVIEW] Would update {path} version to {new_version}")
else:
_write(path, content)
print(f"Updated {path} version to {new_version}")
return list(changes)
+49 -13
View File
@@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow.
from __future__ import annotations from __future__ import annotations
import re
from pkgmgr.core.git.queries import get_tags from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import ( from pkgmgr.core.version.semver import (
SemVer, SemVer,
@@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import (
bump_patch, bump_patch,
find_latest_version, find_latest_version,
) )
from pkgmgr.core.version.source import (
read_cargo_version,
read_changelog_version,
read_debian_changelog_version,
read_flake_version,
read_pkgbuild_version,
read_pyproject_version,
read_spec_version,
)
FILE_SOURCES = {
"pyproject.toml": read_pyproject_version,
"CHANGELOG.md": read_changelog_version,
"flake.nix": read_flake_version,
"PKGBUILD": read_pkgbuild_version,
"RPM spec": read_spec_version,
"debian/changelog": read_debian_changelog_version,
"Cargo.toml": read_cargo_version,
}
_CORE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)")
def determine_current_version() -> SemVer: def _file_versions(repo_dir: str) -> dict[str, SemVer]:
versions: dict[str, SemVer] = {}
for source, read in FILE_SOURCES.items():
match = _CORE_VERSION.match(read(repo_dir) or "")
if match:
versions[source] = SemVer.parse(match.group(1))
return versions
def determine_current_version(repo_dir: str) -> SemVer:
""" """
Determine the current semantic version from Git tags. Determine the highest version across the latest SemVer tag and every
version file the release writes.
Behaviour: Args:
- If there are no tags or no SemVer-compatible tags, return 0.0.0. repo_dir: repository root holding the version files.
- Otherwise, use the latest SemVer tag as current version.
Returns:
The highest version found, or 0.0.0 when no source has one.
""" """
tags = get_tags() versions = _file_versions(repo_dir)
if not tags: latest = find_latest_version(get_tags() or [])
if latest is not None:
versions["tag"] = latest[1]
if not versions:
return SemVer(0, 0, 0) return SemVer(0, 0, 0)
latest = find_latest_version(tags) highest = max(versions.values())
if latest is None: if len(set(versions.values())) > 1:
return SemVer(0, 0, 0) found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
_tag, ver = latest return highest
return ver
def bump_semver(current: SemVer, release_type: str) -> SemVer: def bump_semver(current: SemVer, release_type: str) -> SemVer:
+6 -2
View File
@@ -10,6 +10,7 @@ from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import ( from .files import (
update_cargo_version,
update_changelog, update_changelog,
update_debian_changelog, update_debian_changelog,
update_flake_version, update_flake_version,
@@ -48,7 +49,8 @@ def _release_impl(
# Pull BEFORE making any modifications # Pull BEFORE making any modifications
ensure_clean_and_synced(preview=preview) ensure_clean_and_synced(preview=preview)
current_ver = determine_current_version() repo_root = os.path.dirname(os.path.abspath(pyproject_path))
current_ver = determine_current_version(repo_root)
new_ver = bump_semver(current_ver, release_type) new_ver = bump_semver(current_ver, release_type)
new_ver_str = str(new_ver) new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True) new_tag = new_ver.to_tag(with_prefix=True)
@@ -58,7 +60,6 @@ def _release_impl(
print(f"Current version: {current_ver}") print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})") print(f"New version: {new_ver_str} ({release_type})")
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
paths = resolve_repo_paths(repo_root) paths = resolve_repo_paths(repo_root)
# --- Update versioned files ------------------------------------------------ # --- Update versioned files ------------------------------------------------
@@ -74,6 +75,8 @@ def _release_impl(
update_flake_version(paths.flake_nix, new_ver_str, preview=preview) update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
cargo_files = update_cargo_version(repo_root, new_ver_str, preview=preview)
if paths.arch_pkgbuild: if paths.arch_pkgbuild:
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview) update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
else: else:
@@ -124,6 +127,7 @@ def _release_impl(
paths.arch_pkgbuild, paths.arch_pkgbuild,
paths.rpm_spec, paths.rpm_spec,
paths.debian_changelog, paths.debian_changelog,
*cargo_files,
] ]
existing_files = [ existing_files = [
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p) p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
+38 -1
View File
@@ -1,9 +1,26 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from pkgmgr.actions.branch import close_branch, drop_branch, open_branch from pkgmgr.actions.branch import (
branch_gc,
branch_purge,
close_branch,
drop_branch,
open_branch,
)
from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.repository.dir import get_repo_dir
def _gc_repo_roots(args, ctx: CLIContext) -> list[str]:
if not args.all:
return [main_repo_root(".")]
dirs = [get_repo_dir(ctx.repositories_base_dir, r) for r in ctx.all_repositories]
return [d for d in dirs if os.path.exists(os.path.join(d, ".git"))]
def handle_branch(args, ctx: CLIContext) -> None: def handle_branch(args, ctx: CLIContext) -> None:
@@ -14,7 +31,27 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch open [<name>] [--base <branch>] - pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f] - pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f] - pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
""" """
if args.subcommand in ("gc", "purge"):
try:
roots = _gc_repo_roots(args, ctx)
except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2)
if args.subcommand == "gc":
code = branch_gc(
roots,
apply=args.apply,
as_json=args.json,
include=args.branch,
exclude=args.exclude,
)
else:
code = branch_purge(roots, include=args.branch, exclude=args.exclude)
sys.exit(code)
if args.subcommand == "open": if args.subcommand == "open":
open_branch( open_branch(
name=getattr(args, "name", None), name=getattr(args, "name", None),
+4 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,7 +15,6 @@ from pkgmgr.actions.config.add import interactive_add
from pkgmgr.actions.config.init import config_init from pkgmgr.actions.config.init import config_init
from pkgmgr.actions.config.show import show_config from pkgmgr.actions.config.show import show_config
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.command.run import run_command
from pkgmgr.core.config.save import save_user_config from pkgmgr.core.config.save import save_user_config
from pkgmgr.core.repository.resolve import resolve_repos from pkgmgr.core.repository.resolve import resolve_repos
@@ -108,7 +108,9 @@ def handle_config(args, ctx: CLIContext) -> None:
return return
if args.subcommand == "edit": if args.subcommand == "edit":
run_command(f"nano {user_config_path}") returncode = subprocess.call(["nano", os.path.expanduser(user_config_path)])
if returncode != 0:
sys.exit(returncode)
return return
if args.subcommand == "init": if args.subcommand == "init":
+44
View File
@@ -96,3 +96,47 @@ def add_branch_subparsers(
action="store_true", action="store_true",
help="Skip confirmation prompt and drop the branch directly", help="Skip confirmation prompt and drop the branch directly",
) )
branch_gc = branch_subparsers.add_parser(
"gc",
help=(
"Remove merged and clean branches with their worktrees and compose "
"stacks (dry-run unless --apply)"
),
)
branch_gc.add_argument(
"--apply",
action="store_true",
help="Execute the plan instead of only printing it",
)
branch_gc.add_argument(
"--json",
action="store_true",
help="Print the plan as JSON",
)
branch_purge = branch_subparsers.add_parser(
"purge",
help="Delete unmerged or dirty branches after a confirmation per item",
)
for sub in (branch_gc, branch_purge):
sub.add_argument(
"--all",
action="store_true",
help="Scan every pkgmgr-managed repository instead of the current one",
)
sub.add_argument(
"--branch",
action="append",
default=[],
metavar="NAME",
help="Only this branch (or worktree path); repeatable",
)
sub.add_argument(
"--exclude",
action="append",
default=[],
metavar="NAME",
help="Skip this branch (or worktree path); repeatable",
)
+37
View File
@@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
return None return None
return None return None
def read_cargo_version(repo_dir: str) -> str | None:
"""
Read [package].version from Cargo.toml in repo_dir, if present.
"""
path = os.path.join(repo_dir, "Cargo.toml")
if not os.path.isfile(path):
return None
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore
try:
with open(path, "rb") as f:
version = (tomllib.load(f).get("package") or {}).get("version")
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
return None
return version.strip() if isinstance(version, str) and version.strip() else None
def read_changelog_version(repo_dir: str) -> str | None:
"""
Read the newest release from CHANGELOG.md ("## [X.Y.Z] - date").
"""
path = os.path.join(repo_dir, "CHANGELOG.md")
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
match = re.search(r"(?m)^##\s+\[(v?\d+\.\d+\.\d+)\]", f.read())
except (OSError, UnicodeDecodeError):
return None
return match.group(1).strip() if match else None
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import io
import json
import os
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
from pkgmgr.actions.branch import gc
from pkgmgr.actions.branch.gc_forge import ForgeFacts
from pkgmgr.cli.commands.branch import handle_branch
from pkgmgr.cli.parser import create_parser
GIT_ENV = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.org",
}
def git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", "-c", "commit.gpgsign=false", *args],
cwd=cwd,
env=GIT_ENV,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def docker_available() -> bool:
try:
subprocess.run(["docker", "info"], check=True, capture_output=True)
except (FileNotFoundError, subprocess.CalledProcessError):
return False
return True
class TestBranchGc(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
self.origin = os.path.join(self.root, "origin.git")
self.repo = os.path.join(self.root, "repo")
git(self.root, "init", "--bare", "-b", "main", self.origin)
git(self.root, "init", "-b", "main", self.repo)
git(self.repo, "remote", "add", "origin", self.origin)
self._commit("init")
git(self.repo, "push", "-u", "origin", "main")
self._branch_with_commit("merged")
git(self.repo, "merge", "--no-ff", "-m", "merge merged", "merged")
git(self.repo, "push", "origin", "main")
git(self.repo, "branch", "fresh")
git(self.repo, "push", "origin", "fresh")
self._branch_with_commit("wip")
self._branch_with_commit("squashed")
self._branch_with_commit("locked")
git(self.repo, "merge", "--no-ff", "-m", "merge locked", "locked")
git(self.repo, "push", "origin", "main")
git(self.repo, "remote", "set-head", "origin", "main")
self.merged_wt = os.path.join(self.root, "wt-merged")
git(self.repo, "worktree", "add", self.merged_wt, "merged")
self.dirty_wt = os.path.join(self.root, "wt-dirty")
git(self.repo, "worktree", "add", "-b", "dirty", self.dirty_wt, "merged")
with open(os.path.join(self.dirty_wt, "scratch.txt"), "w") as fh:
fh.write("x")
self.locked_wt = os.path.join(self.root, "wt-locked")
git(self.repo, "worktree", "add", self.locked_wt, "locked")
self.squashed_sha = git(self.repo, "rev-parse", "squashed")
self.forge = ForgeFacts(
merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
)
def tearDown(self) -> None:
self._tmp.cleanup()
def _commit(self, name: str) -> None:
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
fh.write(name)
git(self.repo, "add", f"{name}.txt")
git(self.repo, "commit", "-m", name)
def _branch_with_commit(self, name: str) -> None:
git(self.repo, "checkout", "-b", name, "main")
self._commit(name)
git(self.repo, "push", "origin", name)
git(self.repo, "checkout", "main")
def _lock(self, reason: str) -> None:
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt)
def _plan(self, host_ns: bool, cwds: dict[int, str]) -> gc.Repo:
with (
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value=cwds),
):
return gc.plan_repo(self.repo, [], host_ns)
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
return {item.branch: item for item in plan.items if item.branch}
def test_classifies_every_branch(self) -> None:
items = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(items["main"].state, "protected")
self.assertEqual(items["merged"].state, "merged-clean")
self.assertEqual(items["merged"].merged_by, "ancestor")
self.assertEqual(
items["merged"].actions,
["worktree", "local-branch", "remote-branch:origin"],
)
self.assertTrue(items["merged"].eligible)
self.assertEqual(items["squashed"].merged_by, "pr#7")
self.assertTrue(items["squashed"].eligible)
self.assertEqual(items["fresh"].state, "unmerged")
self.assertEqual(items["fresh"].reason, "no own commits")
self.assertEqual(items["wip"].state, "unmerged")
self.assertEqual(items["wip"].skip, "open-pr #3")
self.assertEqual(items["dirty"].state, "dirty")
def test_busy_worktree_is_skipped(self) -> None:
cwds = {4242: os.path.join(self.merged_wt, "sub")}
items = self._items(self._plan(host_ns=True, cwds=cwds))
self.assertTrue(items["merged"].skip.startswith("process: pid 4242"))
self.assertFalse(items["merged"].eligible)
def test_lock_with_live_pid_is_skipped(self) -> None:
self._lock(f"claude agent x (pid {os.getpid()} on host)")
items = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(items["locked"].skip, f"lock: pid {os.getpid()} alive")
def test_lock_with_dead_pid_is_overridden(self) -> None:
self._lock("claude agent x (pid 999999999 on host)")
items = self._items(self._plan(host_ns=True, cwds={}))
self.assertIsNone(items["locked"].skip)
self.assertTrue(items["locked"].eligible)
def test_lock_outside_host_namespace_is_unknown(self) -> None:
self._lock("claude agent x (pid 999999999 on host)")
items = self._items(self._plan(host_ns=False, cwds={}))
self.assertEqual(items["locked"].skip, "lock: liveness unknown")
def test_lock_without_pid_is_respected(self) -> None:
self._lock("manual hold")
items = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(items["locked"].skip, "lock: manual hold")
def test_forge_unavailable_skips_everything(self) -> None:
self.forge = ForgeFacts(unavailable="no GitHub token")
items = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(items["merged"].skip, "forge unavailable")
self.assertIsNone(items["squashed"].merged_by)
def test_stale_metadata_is_listed_not_pruned(self) -> None:
stub = os.path.join(self.repo, ".git", "worktrees", "stub")
os.makedirs(stub)
open(os.path.join(stub, "commondir"), "w").close()
plan = self._plan(host_ns=True, cwds={})
stale = [i for i in plan.items if i.state == "stale"]
self.assertEqual([i.worktree for i in stale], [stub])
self.assertTrue(os.path.isdir(stub))
def test_execute_removes_worktree_and_branches(self) -> None:
plan = self._plan(host_ns=True, cwds={})
item = self._items(plan)["merged"]
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
with patch.object(gc, "_process_cwds", return_value={}):
gc.execute(plan, item)
self.assertEqual(item.result, "done")
self.assertFalse(os.path.exists(self.merged_wt))
self.assertFalse(os.path.exists(meta))
self.assertEqual(git(self.repo, "branch", "--list", "merged"), "")
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "")
@unittest.skipUnless(docker_available(), "docker is not available")
def test_execute_removes_foreign_owned_worktree_via_container(self) -> None:
with open(os.path.join(self.repo, ".git", "info", "exclude"), "a") as fh:
fh.write("node_modules/\n")
subprocess.run(
[
"docker",
"run",
"--rm",
"-v",
f"{self.merged_wt}:/w",
gc.CLEANUP_IMAGE,
"sh",
"-c",
(
"mkdir -p /w/node_modules/pkg && touch /w/node_modules/pkg/index.js"
" && chown -R 65534:65534 /w/node_modules"
),
],
check=True,
capture_output=True,
)
self.assertTrue(gc._foreign_owned(self.merged_wt))
plan = self._plan(host_ns=True, cwds={})
item = self._items(plan)["merged"]
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
with patch.object(gc, "_process_cwds", return_value={}):
gc.execute(plan, item)
self.assertEqual(item.result, "done")
self.assertFalse(os.path.lexists(self.merged_wt))
self.assertFalse(os.path.exists(meta))
self.assertEqual(git(self.repo, "branch", "--list", "merged"), "")
def test_metadata_kept_when_checkout_survives_removal(self) -> None:
plan = self._plan(host_ns=True, cwds={})
item = self._items(plan)["merged"]
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
with (
patch.object(gc, "_process_cwds", return_value={}),
patch.object(gc, "_foreign_owned", return_value=True),
patch.object(gc, "_docker", return_value="") as docker,
):
gc.execute(plan, item)
docker.assert_called_once_with(
[
"run",
"--rm",
"-v",
f"{self.root}:/w",
gc.CLEANUP_IMAGE,
"rm",
"-rf",
"/w/wt-merged",
]
)
self.assertTrue(item.result.startswith("failed: worktree"))
self.assertTrue(os.path.isdir(self.merged_wt))
self.assertTrue(os.path.isdir(meta))
self.assertNotEqual(git(self.repo, "branch", "--list", "merged"), "")
def test_execute_skips_item_changed_since_plan(self) -> None:
plan = self._plan(host_ns=True, cwds={})
item = self._items(plan)["merged"]
with open(os.path.join(self.merged_wt, "late.txt"), "w") as fh:
fh.write("late")
with patch.object(gc, "_process_cwds", return_value={}):
gc.execute(plan, item)
self.assertTrue(item.result.startswith("skipped: changed since plan"))
self.assertTrue(os.path.isdir(self.merged_wt))
def test_branch_gc_json_and_refusal(self) -> None:
out = io.StringIO()
with (
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value={}),
redirect_stdout(out),
):
code = gc.branch_gc(
[self.repo], apply=False, as_json=True, include=["merged"], exclude=[]
)
data = json.loads(out.getvalue())
self.assertEqual(code, 0)
self.assertEqual([i["branch"] for i in data["repos"][0]["items"]], ["merged"])
with patch.object(gc, "host_pid_namespace", return_value=False):
self.assertEqual(
gc.branch_gc(
[self.repo], apply=True, as_json=False, include=[], exclude=[]
),
2,
)
def test_cli_forwards_gc_flags(self) -> None:
args = create_parser("test").parse_args(
[
"branch",
"gc",
"--apply",
"--json",
"--branch",
"a",
"--branch",
"b",
"--exclude",
"c",
]
)
with (
patch("pkgmgr.cli.commands.branch.main_repo_root", return_value=self.repo),
patch("pkgmgr.cli.commands.branch.branch_gc", return_value=1) as run_gc,
self.assertRaises(SystemExit) as exit_ctx,
):
handle_branch(args, ctx=None)
self.assertEqual(exit_ctx.exception.code, 1)
run_gc.assert_called_once_with(
[self.repo], apply=True, as_json=True, include=["a", "b"], exclude=["c"]
)
if __name__ == "__main__":
unittest.main()
@@ -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,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()