feat(branch-gc): add --merged-into, --sizes and --min-age

This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-01 12:54:19 +01:00
1 parent d1a951ee94
commit 21cecf64fb
4 files changed
+276 -22

No files matched your search

+140 -8
View File
@@ -6,6 +6,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
@@ -63,6 +64,8 @@ class Item:
images: list[str] = field(default_factory=list) images: list[str] = field(default_factory=list)
state: str = "" state: str = ""
merged_by: str | None = None merged_by: str | None = None
merged_into: str | None = None
size_bytes: int | None = None
skip: str | None = None skip: str | None = None
actions: list[str] = field(default_factory=list) actions: list[str] = field(default_factory=list)
reason: str = "" reason: str = ""
@@ -88,6 +91,9 @@ class Repo:
forge: ForgeFacts forge: ForgeFacts
containers: list[Container] | None containers: list[Container] | None
host_ns: bool host_ns: bool
lines: dict[str, tuple[str, set[str]]]
sizes: bool
min_age: float | None
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
items: list[Item] = 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 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: def _merged_by(repo: Repo, sha: str) -> str | None:
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo): if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
return "ancestor" return "ancestor"
number = repo.forge.merged_heads.get(sha) 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( def _skip(
@@ -331,9 +377,20 @@ def _build(
(verdict for verdict in verdicts.values() if verdict != "ancestor"), (verdict for verdict in verdicts.values() if verdict != "ancestor"),
"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}" 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) 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: for project in item.compose_projects:
item.actions += [f"compose-down:{project}", f"images:{project}"] item.actions += [f"compose-down:{project}", f"images:{project}"]
if tree: if tree:
@@ -386,9 +443,51 @@ def _stale(repo_root: str) -> list[Item]:
return items 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( 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: ) -> 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] = [] warnings: list[str] = []
try: try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of # Auto-maintenance runs worktree-prune, which deletes the metadata of
@@ -422,18 +521,28 @@ def plan_repo(
) )
if forge.unavailable: if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}") warnings.append(f"forge unavailable: {forge.unavailable}")
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
repo = Repo( repo = Repo(
repo=repo_root, repo=repo_root,
default=default, default=default,
remotes=remotes, remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected}, protected={
default,
*ALWAYS_PROTECTED,
*head_names,
*forge.protected,
*line_names,
},
first_parent=set( first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines() run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
), ),
forge=forge, forge=forge,
containers=containers, containers=containers,
host_ns=host_ns, host_ns=host_ns,
lines=lines,
sizes=sizes,
min_age=min_age,
warnings=warnings, warnings=warnings,
) )
repo.items = _items(repo) repo.items = _items(repo)
@@ -546,6 +655,8 @@ def _line(item: Item) -> str:
parts.append(item.skip) parts.append(item.skip)
if item.worktree and item.state != "stale": if item.worktree and item.state != "stale":
parts.append(f"worktree {item.worktree}") 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: if item.compose_projects:
parts.append("stack " + ", ".join(item.compose_projects)) parts.append("stack " + ", ".join(item.compose_projects))
if item.remotes: if item.remotes:
@@ -566,6 +677,7 @@ def _render(
{ {
"repo": repo.repo, "repo": repo.repo,
"default": repo.default, "default": repo.default,
"merged_into": list(repo.lines),
"warnings": repo.warnings, "warnings": repo.warnings,
"items": [asdict(i) for i in repo.items if chosen(i)], "items": [asdict(i) for i in repo.items if chosen(i)],
} }
@@ -591,11 +703,14 @@ def _render(
def _prepare( 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: ) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace() host_ns = host_pid_namespace()
containers = list_compose_containers() 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 = [ unknown = [
name name
for name in include for name in include
@@ -633,6 +748,9 @@ def branch_gc(
as_json: bool, as_json: bool,
include: list[str], include: list[str],
exclude: list[str], exclude: list[str],
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> int: ) -> int:
""" """
Plan, and with apply=True execute, the cleanup of merged and clean branches. 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. as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these. include: only items whose branch or worktree path matches one of these.
exclude: drop 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: Returns:
0 on success, 1 when an applied item failed, 2 when refused. 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(): if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr) print(REFUSAL, file=sys.stderr)
return 2 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: if prepared is None:
return 2 return 2
repos, host_ns, chosen = prepared repos, host_ns, chosen = prepared
@@ -669,7 +791,13 @@ def branch_gc(
def branch_purge( 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: ) -> int:
""" """
Delete unmerged or dirty items after a y/N confirmation per item. 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. repo_roots: main worktree roots of the repositories to scan.
include: only items whose branch or worktree path matches one of these. include: only items whose branch or worktree path matches one of these.
exclude: drop 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: Returns:
0 on success, 1 when a confirmed item failed, 2 when refused. 0 on success, 1 when a confirmed item failed, 2 when refused.
@@ -685,7 +816,8 @@ def branch_purge(
if not host_pid_namespace(): if not host_pid_namespace():
print(REFUSAL, file=sys.stderr) print(REFUSAL, file=sys.stderr)
return 2 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: if prepared is None:
return 2 return 2
repos, _, chosen = prepared 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 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 gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]... - pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
""" """
if args.subcommand in ("gc", "purge"): if args.subcommand in ("gc", "purge"):
try: try:
@@ -40,16 +42,17 @@ def handle_branch(args, ctx: CLIContext) -> None:
except GitBaseError as exc: except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr) print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2) 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": if args.subcommand == "gc":
code = branch_gc( code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
roots,
apply=args.apply,
as_json=args.json,
include=args.branch,
exclude=args.exclude,
)
else: else:
code = branch_purge(roots, include=args.branch, exclude=args.exclude) code = branch_purge(roots, **options)
sys.exit(code) sys.exit(code)
if args.subcommand == "open": if args.subcommand == "open":
+22
View File
@@ -140,3 +140,25 @@ def add_branch_subparsers(
metavar="NAME", metavar="NAME",
help="Skip this branch (or worktree path); repeatable", 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: def _lock(self, reason: str) -> None:
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt) 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 ( with (
patch.object(gc, "fetch_forge_facts", return_value=self.forge), patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value=cwds), 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]: def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
return {item.branch: item for item in plan.items if item.branch} 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["wip"].skip, "open-pr #3")
self.assertEqual(items["dirty"].state, "dirty") 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: def test_busy_worktree_is_skipped(self) -> None:
cwds = {4242: os.path.join(self.merged_wt, "sub")} cwds = {4242: os.path.join(self.merged_wt, "sub")}
items = self._items(self._plan(host_ns=True, cwds=cwds)) items = self._items(self._plan(host_ns=True, cwds=cwds))
@@ -276,16 +341,34 @@ class TestBranchGc(unittest.TestCase):
redirect_stdout(out), redirect_stdout(out),
): ):
code = gc.branch_gc( 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()) data = json.loads(out.getvalue())
self.assertEqual(code, 0) 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): with patch.object(gc, "host_pid_namespace", return_value=False):
self.assertEqual( self.assertEqual(
gc.branch_gc( 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, 2,
) )
@@ -303,6 +386,13 @@ class TestBranchGc(unittest.TestCase):
"b", "b",
"--exclude", "--exclude",
"c", "c",
"--merged-into",
"origin/line",
"--merged-into",
"fork/line",
"--sizes",
"--min-age",
"24",
] ]
) )
with ( with (
@@ -314,7 +404,14 @@ class TestBranchGc(unittest.TestCase):
self.assertEqual(exit_ctx.exception.code, 1) self.assertEqual(exit_ctx.exception.code, 1)
run_gc.assert_called_once_with( 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,
) )