Compare commits

...
2 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 08738d812e Release version 2.3.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
CodeQL Advanced / Check security (none, actions) (push) Waiting to run
CodeQL Advanced / Check security (none, python) (push) Waiting to run
Test Units / test-unit (push) Waiting to run
Test Code Integration / test-integration (push) Waiting to run
Test OS Containers / test-env-virtual (arch) (push) Waiting to run
Test OS Containers / test-env-virtual (centos) (push) Waiting to run
Test OS Containers / test-env-virtual (debian) (push) Waiting to run
Test OS Containers / test-env-virtual (fedora) (push) Waiting to run
Test OS Containers / test-env-virtual (ubuntu) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Waiting to run
Test End-To-End / test-e2e (arch) (push) Waiting to run
Test End-To-End / test-e2e (centos) (push) Waiting to run
Test End-To-End / test-e2e (debian) (push) Waiting to run
Test End-To-End / test-e2e (fedora) (push) Waiting to run
Test End-To-End / test-e2e (ubuntu) (push) Waiting to run
Test Virgin User / test-virgin-user (arch) (push) Waiting to run
Test Virgin User / test-virgin-user (centos) (push) Waiting to run
Test Virgin User / test-virgin-user (debian) (push) Waiting to run
Test Virgin User / test-virgin-user (fedora) (push) Waiting to run
Test Virgin User / test-virgin-user (ubuntu) (push) Waiting to run
Test Virgin Root / test-virgin-root (arch) (push) Waiting to run
Test Virgin Root / test-virgin-root (centos) (push) Waiting to run
Test Virgin Root / test-virgin-root (debian) (push) Waiting to run
Test Virgin Root / test-virgin-root (fedora) (push) Waiting to run
Test Virgin Root / test-virgin-root (ubuntu) (push) Waiting to run
ShellCheck / lint-shell (push) Waiting to run
Ruff (Python code sniffer) / lint-python (push) Waiting to run
Docker Linter / Lint Dockerfile (push) Waiting to run
2026-11-01 15:14:53 +01:00
Kevin Veen-Birkenbach 21cecf64fb feat(branch-gc): add --merged-into, --sizes and --min-age 2026-11-01 12:54:19 +01:00
10 changed files with 308 additions and 26 deletions

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [2.3.0] - 2026-11-01
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
## [2.2.0] - 2026-11-01
Added:
+1 -1
View File
@@ -32,7 +32,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "2.2.0";
version = "2.3.0";
# Use the git repo as source
src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=2.2.0
pkgver=2.3.0
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+11
View File
@@ -1,3 +1,14 @@
package-manager (2.3.0-1) unstable; urgency=medium
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 15:14:53 +0100
package-manager (2.2.0-1) unstable; urgency=medium
Added:
+9 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 2.2.0
Version: 2.3.0
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,14 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.3.0-1
- Added:
- - Branch: *gc --merged-into REF* treats items merged into REF as merged
- - Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- - Branch: *--sizes* adds each worktree's disk usage to the plan
- - Branch: *--min-age HOURS* skips items with a commit or index that recent
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.2.0-1
- Added:
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "2.2.0"
version = "2.3.0"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.9"
+140 -8
View File
@@ -6,6 +6,7 @@ import re
import shutil
import subprocess
import sys
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
@@ -63,6 +64,8 @@ class Item:
images: list[str] = field(default_factory=list)
state: str = ""
merged_by: str | None = None
merged_into: str | None = None
size_bytes: int | None = None
skip: str | None = None
actions: list[str] = field(default_factory=list)
reason: str = ""
@@ -88,6 +91,9 @@ class Repo:
forge: ForgeFacts
containers: list[Container] | None
host_ns: bool
lines: dict[str, tuple[str, set[str]]]
sizes: bool
min_age: float | None
warnings: list[str] = field(default_factory=list)
items: list[Item] = field(default_factory=list)
@@ -234,11 +240,51 @@ def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
return True
def _in_line(repo: Repo, sha: str) -> str | None:
for line, (tip, first_parent) in repo.lines.items():
if sha == tip:
continue
if sha in first_parent or _is_ancestor(sha, line, repo.repo):
return line
return None
def _merged_by(repo: Repo, sha: str) -> str | None:
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
return "ancestor"
number = repo.forge.merged_heads.get(sha)
return f"pr#{number}" if number else None
if number:
return f"pr#{number}"
line = _in_line(repo, sha)
return f"line:{line}" if line else None
def _du_bytes(path: str) -> int | None:
out = subprocess.run(
["du", "-sk", path], check=False, capture_output=True, text=True
).stdout.split()
return int(out[0]) * 1024 if out and out[0].isdigit() else None
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
stamps = [
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
]
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
if os.path.isfile(index):
stamps.append(int(os.path.getmtime(index)))
return (time.time() - max(stamps)) / 3600
def _human(size: int) -> str:
value = float(size)
for unit in ("K", "M", "G"):
value /= 1024
if value < 1024:
return f"{value:.1f}{unit}"
return f"{value / 1024:.1f}T"
def _skip(
@@ -331,9 +377,20 @@ def _build(
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
"ancestor",
)
item.merged_into = (
item.merged_by.removeprefix("line:")
if item.merged_by.startswith("line:")
else repo.default
)
item.reason = f"merged by {item.merged_by}"
if repo.sizes and checkout and not tree.main:
item.size_bytes = _du_bytes(tree.path)
item.skip = _skip(repo, item, tree, checkout, cwds)
if item.skip is None and repo.min_age is not None and verdicts:
age = _age_hours(repo, list(verdicts), tree if checkout else None)
if age < repo.min_age:
item.skip = f"younger than {repo.min_age:g} h"
for project in item.compose_projects:
item.actions += [f"compose-down:{project}", f"images:{project}"]
if tree:
@@ -386,9 +443,51 @@ def _stale(repo_root: str) -> list[Item]:
return items
def _lines(
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
lines: dict[str, tuple[str, set[str]]] = {}
names: set[str] = set()
for ref in refs:
try:
tip = run(
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root
)
except GitRunError:
warnings.append(f"merged-into ref {ref} not found")
continue
first_parent = set(
run(["rev-list", "--first-parent", ref], cwd=repo_root).splitlines()
)
lines[ref] = (tip, first_parent)
owner = next((r for r in remotes if ref.startswith(r + "/")), None)
names.add(ref[len(owner) + 1 :] if owner else ref)
return lines, names
def plan_repo(
repo_root: str, containers: list[Container] | None, host_ns: bool
repo_root: str,
containers: list[Container] | None,
host_ns: bool,
*,
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> Repo:
"""
Scan one repository and classify every branch and worktree.
Args:
repo_root: main worktree root of the repository.
containers: compose containers on the host; None when docker is unavailable.
host_ns: whether this process runs in the host PID namespace.
merged_into: integration lines (refs) that also count as merged; each is protected.
sizes: measure each worktree's disk usage into size_bytes.
min_age: skip items whose newest commit or worktree index is younger (hours).
Returns:
The repository with its classified items.
"""
warnings: list[str] = []
try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of
@@ -422,18 +521,28 @@ def plan_repo(
)
if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}")
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
repo = Repo(
repo=repo_root,
default=default,
remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected},
protected={
default,
*ALWAYS_PROTECTED,
*head_names,
*forge.protected,
*line_names,
},
first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
),
forge=forge,
containers=containers,
host_ns=host_ns,
lines=lines,
sizes=sizes,
min_age=min_age,
warnings=warnings,
)
repo.items = _items(repo)
@@ -546,6 +655,8 @@ def _line(item: Item) -> str:
parts.append(item.skip)
if item.worktree and item.state != "stale":
parts.append(f"worktree {item.worktree}")
if item.size_bytes is not None:
parts.append(f"size {_human(item.size_bytes)}")
if item.compose_projects:
parts.append("stack " + ", ".join(item.compose_projects))
if item.remotes:
@@ -566,6 +677,7 @@ def _render(
{
"repo": repo.repo,
"default": repo.default,
"merged_into": list(repo.lines),
"warnings": repo.warnings,
"items": [asdict(i) for i in repo.items if chosen(i)],
}
@@ -591,11 +703,14 @@ def _render(
def _prepare(
repo_roots: list[str], include: list[str], exclude: list[str]
repo_roots: list[str],
include: list[str],
exclude: list[str],
options: dict,
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace()
containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns) for root in repo_roots]
repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots]
unknown = [
name
for name in include
@@ -633,6 +748,9 @@ def branch_gc(
as_json: bool,
include: list[str],
exclude: list[str],
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> int:
"""
Plan, and with apply=True execute, the cleanup of merged and clean branches.
@@ -643,6 +761,9 @@ def branch_gc(
as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
merged_into: integration lines (refs) that also count as merged.
sizes: measure each worktree's disk usage into the plan.
min_age: skip items younger than this many hours.
Returns:
0 on success, 1 when an applied item failed, 2 when refused.
@@ -650,7 +771,8 @@ def branch_gc(
if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
prepared = _prepare(repo_roots, include, exclude, options)
if prepared is None:
return 2
repos, host_ns, chosen = prepared
@@ -669,7 +791,13 @@ def branch_gc(
def branch_purge(
repo_roots: list[str], *, include: list[str], exclude: list[str]
repo_roots: list[str],
*,
include: list[str],
exclude: list[str],
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> int:
"""
Delete unmerged or dirty items after a y/N confirmation per item.
@@ -678,6 +806,9 @@ def branch_purge(
repo_roots: main worktree roots of the repositories to scan.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
merged_into: integration lines (refs) that also count as merged.
sizes: measure each worktree's disk usage into the prompt line.
min_age: skip items younger than this many hours.
Returns:
0 on success, 1 when a confirmed item failed, 2 when refused.
@@ -685,7 +816,8 @@ def branch_purge(
if not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
prepared = _prepare(repo_roots, include, exclude, options)
if prepared is None:
return 2
repos, _, chosen = prepared
+11 -8
View File
@@ -32,7 +32,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
"""
if args.subcommand in ("gc", "purge"):
try:
@@ -40,16 +42,17 @@ def handle_branch(args, ctx: CLIContext) -> None:
except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2)
options = {
"include": args.branch,
"exclude": args.exclude,
"merged_into": args.merged_into,
"sizes": args.sizes,
"min_age": args.min_age,
}
if args.subcommand == "gc":
code = branch_gc(
roots,
apply=args.apply,
as_json=args.json,
include=args.branch,
exclude=args.exclude,
)
code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
else:
code = branch_purge(roots, include=args.branch, exclude=args.exclude)
code = branch_purge(roots, **options)
sys.exit(code)
if args.subcommand == "open":
+22
View File
@@ -140,3 +140,25 @@ def add_branch_subparsers(
metavar="NAME",
help="Skip this branch (or worktree path); repeatable",
)
sub.add_argument(
"--merged-into",
action="append",
default=[],
metavar="REF",
help=(
"Also count an item as merged when its commits are in REF (an "
"integration line); REF is protected and a checkout at its tip "
"counts as fresh; repeatable"
),
)
sub.add_argument(
"--sizes",
action="store_true",
help="Measure each worktree's disk usage (du) into the plan",
)
sub.add_argument(
"--min-age",
type=float,
metavar="HOURS",
help="Skip items whose newest commit or worktree index is younger",
)
+103 -6
View File
@@ -98,12 +98,40 @@ class TestBranchGc(unittest.TestCase):
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:
def _plan(
self,
host_ns: bool,
cwds: dict[int, str],
merged_into: list[str] | None = None,
sizes: bool = False,
min_age: float | None = None,
) -> gc.Repo:
with (
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value=cwds),
):
return gc.plan_repo(self.repo, [], host_ns)
return gc.plan_repo(
self.repo,
[],
host_ns,
merged_into=merged_into or [],
sizes=sizes,
min_age=min_age,
)
def _integration_line(self) -> str:
git(self.repo, "checkout", "-b", "line", "main")
self._commit("line-base")
git(self.repo, "checkout", "-b", "feat", "line")
self._commit("feat")
git(self.repo, "checkout", "line")
git(self.repo, "merge", "--no-ff", "-m", "merge feat", "feat")
git(self.repo, "push", "origin", "line", "feat")
git(self.repo, "branch", "start", "line")
git(self.repo, "checkout", "main")
feat_wt = os.path.join(self.root, "wt-feat")
git(self.repo, "worktree", "add", feat_wt, "feat")
return feat_wt
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
return {item.branch: item for item in plan.items if item.branch}
@@ -127,6 +155,43 @@ class TestBranchGc(unittest.TestCase):
self.assertEqual(items["wip"].skip, "open-pr #3")
self.assertEqual(items["dirty"].state, "dirty")
def test_merged_into_counts_commits_in_an_integration_line(self) -> None:
self._integration_line()
before = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(before["feat"].state, "unmerged")
self.assertIsNone(before["feat"].merged_into)
for ref in ("line", "origin/line"):
with self.subTest(ref=ref):
items = self._items(
self._plan(host_ns=True, cwds={}, merged_into=[ref])
)
self.assertEqual(items["feat"].state, "merged-clean")
self.assertEqual(items["feat"].merged_by, f"line:{ref}")
self.assertEqual(items["feat"].merged_into, ref)
self.assertTrue(items["feat"].eligible)
self.assertEqual(items["line"].state, "protected")
self.assertEqual(items["start"].state, "unmerged")
self.assertEqual(items["merged"].merged_into, "origin/main")
def test_unknown_merged_into_ref_is_a_warning(self) -> None:
plan = self._plan(host_ns=True, cwds={}, merged_into=["no-such-line"])
self.assertIn("merged-into ref no-such-line not found", plan.warnings)
self.assertEqual(self._items(plan)["merged"].state, "merged-clean")
def test_sizes_and_min_age(self) -> None:
items = self._items(self._plan(host_ns=True, cwds={}, sizes=True))
self.assertGreater(items["merged"].size_bytes, 0)
self.assertIsNone(items["squashed"].size_bytes)
self.assertIn("size ", gc._line(items["merged"]))
young = self._items(self._plan(host_ns=True, cwds={}, min_age=1000))
self.assertEqual(young["merged"].skip, "younger than 1000 h")
self.assertFalse(young["merged"].eligible)
old = self._items(self._plan(host_ns=True, cwds={}, min_age=0))
self.assertIsNone(old["merged"].skip)
def test_busy_worktree_is_skipped(self) -> None:
cwds = {4242: os.path.join(self.merged_wt, "sub")}
items = self._items(self._plan(host_ns=True, cwds=cwds))
@@ -276,16 +341,34 @@ class TestBranchGc(unittest.TestCase):
redirect_stdout(out),
):
code = gc.branch_gc(
[self.repo], apply=False, as_json=True, include=["merged"], exclude=[]
[self.repo],
apply=False,
as_json=True,
include=["merged"],
exclude=[],
merged_into=["origin/main"],
sizes=True,
min_age=None,
)
data = json.loads(out.getvalue())
self.assertEqual(code, 0)
self.assertEqual([i["branch"] for i in data["repos"][0]["items"]], ["merged"])
self.assertEqual(data["repos"][0]["merged_into"], ["origin/main"])
items = data["repos"][0]["items"]
self.assertEqual([i["branch"] for i in items], ["merged"])
self.assertEqual(items[0]["merged_into"], "origin/main")
self.assertGreater(items[0]["size_bytes"], 0)
with patch.object(gc, "host_pid_namespace", return_value=False):
self.assertEqual(
gc.branch_gc(
[self.repo], apply=True, as_json=False, include=[], exclude=[]
[self.repo],
apply=True,
as_json=False,
include=[],
exclude=[],
merged_into=[],
sizes=False,
min_age=None,
),
2,
)
@@ -303,6 +386,13 @@ class TestBranchGc(unittest.TestCase):
"b",
"--exclude",
"c",
"--merged-into",
"origin/line",
"--merged-into",
"fork/line",
"--sizes",
"--min-age",
"24",
]
)
with (
@@ -314,7 +404,14 @@ class TestBranchGc(unittest.TestCase):
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"]
[self.repo],
apply=True,
as_json=True,
include=["a", "b"],
exclude=["c"],
merged_into=["origin/line", "fork/line"],
sizes=True,
min_age=24.0,
)