Compare commits

...
3 Commits
Author SHA1 Message Date
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
13 changed files with 1530 additions and 18 deletions

No files matched your search

+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),
+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: