diff --git a/src/pkgmgr/actions/branch/gc.py b/src/pkgmgr/actions/branch/gc.py index f4cc039..d2c3fcf 100644 --- a/src/pkgmgr/actions/branch/gc.py +++ b/src/pkgmgr/actions/branch/gc.py @@ -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 diff --git a/src/pkgmgr/cli/commands/branch.py b/src/pkgmgr/cli/commands/branch.py index 4e52665..dafce80 100644 --- a/src/pkgmgr/cli/commands/branch.py +++ b/src/pkgmgr/cli/commands/branch.py @@ -32,7 +32,9 @@ def handle_branch(args, ctx: CLIContext) -> None: - pkgmgr branch close [] [--base ] [--force|-f] - pkgmgr branch drop [] [--base ] [--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": diff --git a/src/pkgmgr/cli/parser/branch_cmd.py b/src/pkgmgr/cli/parser/branch_cmd.py index 3434b61..17b1f5c 100644 --- a/src/pkgmgr/cli/parser/branch_cmd.py +++ b/src/pkgmgr/cli/parser/branch_cmd.py @@ -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", + ) diff --git a/tests/integration/test_branch_gc.py b/tests/integration/test_branch_gc.py index bd020dd..43ee9ca 100644 --- a/tests/integration/test_branch_gc.py +++ b/tests/integration/test_branch_gc.py @@ -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, )