diff --git a/README.md b/README.md index 16f0387..0191c99 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ versioning features it can drive **end-to-end workflows**: | `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow | | `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays | | `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes | -| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.` section in the repository's own config file (`git config --file /config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_` and `GIT_CONFIG_VALUE_`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes//HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes//`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir= symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch..deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item. Meant for working clones and their worktrees, not for a bare server repository. Not mirrored from `git branch --delete-merged`: git also keeps a branch whose push would update its own upstream; `--local` does not. Known residuals: a worktree, rebase or symbolic ref that starts holding a branch between its last re-check and its `update-ref -d` is not seen; a `deleteMerged` or upstream config change after the plan is not re-checked before the delete; an entry under `/worktrees/` that is no git dir (for example an empty leftover) refuses the repository. Reads no forge token and never fetches, pushes, deletes a remote branch, runs docker or repository hooks, touches a worktree, compose stack, volume or image, or refreshes the branch tree file (rerun `pkgmgr branch tree` for that) | +| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.` section in the repository's own config file (`git config --file /config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_` and `GIT_CONFIG_VALUE_`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes//HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes//`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir= symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch..deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item (also one that exists only on a remote). Meant for working clones and their worktrees, not for a bare server repository. Not mirrored from `git branch --delete-merged`: git also keeps a branch whose push would update its own upstream; `--local` does not. A branch is deleted before its planned local upstream; right before each delete its holds and then its `deleteMerged` and the local upstreams of every branch still present are read again, and a branch one of them now keeps is skipped as changed since the plan (so is each branch of a local upstream cycle). Known residuals: a worktree, rebase or symbolic ref that starts holding a branch while its config is re-read, or a config change between that read and its `update-ref -d`, is not seen; an entry under `/worktrees/` that is no git dir (for example an empty leftover) refuses the repository. Reads no forge token and never fetches, pushes, deletes a remote branch, runs docker or repository hooks, touches a worktree, compose stack, volume or image, or refreshes the branch tree file (rerun `pkgmgr branch tree` for that) | | `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching | | `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one | | `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem | diff --git a/src/pkgmgr/actions/branch/gc.py b/src/pkgmgr/actions/branch/gc.py index 1a229aa..9c4d7bd 100644 --- a/src/pkgmgr/actions/branch/gc.py +++ b/src/pkgmgr/actions/branch/gc.py @@ -8,6 +8,7 @@ import sys import time from collections.abc import Callable from dataclasses import asdict, dataclass, field +from graphlib import CycleError, TopologicalSorter from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook from pkgmgr.core.git.errors import GitBaseError, GitRunError @@ -1455,23 +1456,40 @@ def _keep_by_config(repo: Repo, chosen: Callable[[Item], bool]) -> None: chosen: the --branch and --exclude filter; only an eligible chosen item is deleted, every other branch stays. """ - reasons = { - name: f"kept: branch.{name}.deleteMerged is false" for name in repo.opted_out - } pending = { item.branch: item for item in repo.items if item.eligible and chosen(item) } + for name, reason in _config_keeps( + repo.opted_out, repo.upstreams, set(pending) + ).items(): + item = pending[name] + item.actions = [] + item.skip = item.skip or reason + + +def _config_keeps( + opted_out: set[str], upstreams: set[tuple[str, str]], pending: set[str] +) -> dict[str, str]: + """ + Args: + opted_out: branches whose `branch..deleteMerged` is false. + upstreams: (branch, local upstream branch) pairs. + pending: the branches that would be deleted; every other stays. + + Returns: + Pending branch -> keep reason, for each one opted out or the local + upstream of a branch that stays, also of one kept so. + """ + reasons = {name: f"kept: branch.{name}.deleteMerged is false" for name in opted_out} + leaving = set(pending) while True: - for child, parent in sorted(repo.upstreams): - if child not in pending: + for child, parent in sorted(upstreams): + if child not in leaving: reasons.setdefault(parent, f"kept: local upstream of {child}") - kept = reasons.keys() & pending.keys() + kept = reasons.keys() & leaving if not kept: - return - for name in kept: - item = pending.pop(name) - item.actions = [] - item.skip = item.skip or reasons[name] + return {name: reasons[name] for name in pending - leaving} + leaving -= kept def _pins( @@ -1638,20 +1656,7 @@ def execute(repo: Repo, item: Item) -> None: notes.append(f"image kept: {image}") if removes_tree: _remove_worktree(repo.repo, item.worktree) - if "local-branch" in item.actions and repo.scope == "local": - _git( - [ - "update-ref", - "--no-deref", - "-d", - f"refs/heads/{item.branch}", - item.local_sha, - ], - repo.repo, - ) - note = _drop_branch_config(repo.repo, item.branch) - notes += [note] if note else [] - elif "local-branch" in item.actions: + if "local-branch" in item.actions: _git(["branch", "-D", "--", item.branch], repo.repo) for remote, sha in item.remotes.items(): if f"remote-branch:{remote}" not in item.actions: @@ -1789,6 +1794,60 @@ def _gone(repo: Repo) -> str: return f"target {', '.join(gone)} is gone" if gone else "" +def _delete_order(repo: Repo, items: list[Item]) -> list[Item]: + """ + Args: + repo: a repository planned with scope `local`. + items: its eligible chosen items, in plan order. + + Returns: + The items with each branch before its local upstream, as the plan + read them; plan order on an upstream cycle, whose members the + re-check of _delete_local then keeps. + """ + by_name = {item.branch: item for item in items} + graph: dict[str, set[str]] = {name: set() for name in by_name} + for child, parent in sorted(repo.upstreams): + if child != parent and child in by_name and parent in by_name: + graph[parent].add(child) + try: + return [by_name[name] for name in TopologicalSorter(graph).static_order()] + except CycleError: + return items + + +def _delete_local(repo: Repo, item: Item) -> None: + """ + Args: + repo: a repository planned with scope `local`. + item: an eligible item, deleted after every planned branch whose + local upstream it is; it ends done, or skipped when its holds, + then its `deleteMerged` and the upstreams of every branch still + present, re-read right before the delete, keep it. + + Raises: + GitBaseError: when git cannot re-read the repository or the sha moved. + OSError: when a state file cannot be read. + ValueError: when a state file is not UTF-8. + """ + fresh = _rebuild(repo, item) + if _fingerprint(fresh) != _fingerprint(item): + item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})" + return + kept = _config_keeps( + _opted_out(repo.repo), _local_upstreams(repo.repo), {item.branch} + ).get(item.branch) + if kept: + item.result = f"skipped: changed since plan ({kept})" + return + _git( + ["update-ref", "--no-deref", "-d", f"refs/heads/{item.branch}", item.local_sha], + repo.repo, + ) + note = _drop_branch_config(repo.repo, item.branch) + item.result = "; ".join(["done", *([note] if note else [])]) + + def _apply(repo: Repo, item: Item) -> None: """ Execute one planned item; under scope `local` without raising. @@ -1806,7 +1865,7 @@ def _apply(repo: Repo, item: Item) -> None: item.result = f"skipped: {gone}" return try: - execute(repo, item) + _delete_local(repo, item) except (RuntimeError, OSError, ValueError) as exc: item.result = f"failed: {_describe(exc)}" @@ -1893,8 +1952,11 @@ def cleanup( merged branches without a worktree, local and remote; `all` does both in one pass; `local` deletes, with `update-ref -d` against the planned sha, each local branch that no worktree, rebase or - bisect holds, that _keep_by_config does not keep and whose tip - is an ancestor of the remote-tracking + bisect holds, that _keep_by_config does not keep, both re-checked + right before its delete, which follows the deletes of the + branches it is the local upstream of, and whose tip is an + ancestor of the + remote-tracking default or a merged_into line, refuses a repository without such a default, and reads no forge token, runs no fetch, push, docker, hook or prune. @@ -2001,14 +2063,14 @@ def _run( if apply: for repo in repos: - for item in repo.items: - if item.eligible and chosen(item): - _apply(repo, item) - print( - f"[{options['scope']}] {item.branch or item.worktree}: " - f"{item.result}", - file=sys.stderr, - ) + todo = [item for item in repo.items if item.eligible and chosen(item)] + for item in _delete_order(repo, todo) if repo.scope == "local" else todo: + _apply(repo, item) + print( + f"[{options['scope']}] {item.branch or item.worktree}: " + f"{item.result}", + file=sys.stderr, + ) _render(repos, host_ns, chosen, as_json) return _exit_code(repos, apply) diff --git a/tests/integration/test_branch_cleanup_local.py b/tests/integration/test_branch_cleanup_local.py index 21b6229..2e9fb80 100644 --- a/tests/integration/test_branch_cleanup_local.py +++ b/tests/integration/test_branch_cleanup_local.py @@ -8,7 +8,7 @@ import subprocess import sys import tempfile import unittest -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager, redirect_stderr, redirect_stdout from types import SimpleNamespace from unittest.mock import patch @@ -74,6 +74,14 @@ class TestBranchCleanupLocal(unittest.TestCase): self.root = os.path.realpath(self._tmp.name) self.addCleanup(os.chdir, os.getcwd()) os.chdir(self.root) + self.home = os.path.join(self.root, "home") + os.makedirs(self.home) + for patcher in ( + patch.dict(os.environ, {"HOME": self.home}), + patch("pkgmgr.core.git.run._passwd_home", return_value=self.home), + ): + patcher.start() + self.addCleanup(patcher.stop) 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) @@ -1687,6 +1695,228 @@ class TestBranchCleanupLocal(unittest.TestCase): self.assertEqual(self._results(run), {"done": "done", "local-done": "done"}) self.assertFalse(os.path.exists(marker)) + def test_config_keep_made_after_the_plan_skips_the_delete(self) -> None: + real = gc._rebuild + + def late_keep(repo: gc.Repo, item: gc.Item) -> gc.Item: + fresh = real(repo, item) + if item.branch == "done": + git(self.repo, "branch", "kid", "wip") + git(self.repo, "config", "branch.kid.remote", ".") + git(self.repo, "config", "branch.kid.merge", "refs/heads/done") + if item.branch == "local-done": + git(self.repo, "config", "branch.local-done.deleteMerged", "false") + return fresh + + with patch.object(gc, "_rebuild", side_effect=late_keep): + run = self._cleanup("local", apply=True) + + self.assertEqual(run.code, 0) + self.assertEqual( + self._results(run), + { + "done": "skipped: changed since plan (kept: local upstream of kid)", + "local-done": ( + "skipped: changed since plan " + "(kept: branch.local-done.deleteMerged is false)" + ), + }, + ) + heads = self._refs(self.repo, "refs/heads") + self.assertIn("refs/heads/done", heads) + self.assertIn("refs/heads/local-done", heads) + self.assertEqual( + self._branch_config("done"), ["branch.done.remote", "branch.done.merge"] + ) + + def _family(self) -> None: + self._set_head() + git(self.repo, "branch", "a-base", "done") + git(self.repo, "branch", "z-kid", "done") + git(self.repo, "config", "branch.z-kid.remote", ".") + git(self.repo, "config", "branch.z-kid.merge", "refs/heads/a-base") + + def _apply_after(self, change: Callable[[], None]) -> SimpleNamespace: + """ + Args: + change: run once after the plan, before the first re-check of + `--local --apply --branch a-base --branch z-kid`. + + Returns: + As _cleanup returns it. + """ + real = gc._rebuild + changes = [change] + + def late(repo: gc.Repo, item: gc.Item) -> gc.Item: + if changes: + changes.pop()() + return real(repo, item) + + with patch.object(gc, "_rebuild", side_effect=late): + return self._cleanup("local", apply=True, include=("a-base", "z-kid")) + + def assertFamilyKept(self, run: SimpleNamespace) -> None: + heads = self._refs(self.repo, "refs/heads") + self.assertIn("refs/heads/a-base", heads) + self.assertIn("refs/heads/z-kid", heads) + self.assertFalse((run.items["a-base"]["result"] or "").startswith("done")) + self.assertEqual( + git(self.repo, "config", "--get", "branch.z-kid.merge"), "refs/heads/a-base" + ) + + def test_kid_held_before_the_plan_keeps_its_base(self) -> None: + self._family() + git(self.repo, "worktree", "add", os.path.join(self.root, "wt-kid"), "z-kid") + + run = self._cleanup("local", apply=True, include=("a-base", "z-kid")) + + self.assertEqual(run.items["a-base"]["skip"], "kept: local upstream of z-kid") + self.assertFamilyKept(run) + + def test_kid_held_after_the_plan_keeps_its_base(self) -> None: + self._family() + kid = os.path.join(self.root, "wt-kid") + + run = self._apply_after(lambda: git(self.repo, "worktree", "add", kid, "z-kid")) + + self.assertEqual(run.code, 0) + self.assertTrue(run.items["z-kid"]["result"].startswith("skipped: changed")) + self.assertEqual( + run.items["a-base"]["result"], + "skipped: changed since plan (kept: local upstream of z-kid)", + ) + self.assertFamilyKept(run) + + def test_kid_moved_after_the_plan_keeps_its_base(self) -> None: + self._family() + + def move() -> None: + tree = os.path.join(self.root, "wt-tmp") + git(self.repo, "worktree", "add", "--detach", tree, "z-kid") + with open(os.path.join(tree, "kid.txt"), "w", encoding="utf-8") as fh: + fh.write("kid") + git(tree, "add", "kid.txt") + git(tree, "commit", "-m", "kid") + moved = git(tree, "rev-parse", "HEAD") + git(self.repo, "update-ref", "refs/heads/z-kid", moved) + git(self.repo, "worktree", "remove", "--force", tree) + + run = self._apply_after(move) + + self.assertTrue(run.items["z-kid"]["result"].startswith("skipped: changed")) + self.assertFamilyKept(run) + + def test_unchanged_kid_is_deleted_before_its_base(self) -> None: + self._family() + + run = self._cleanup("local", apply=True, include=("a-base", "z-kid")) + + self.assertEqual(run.code, 0) + self.assertEqual(self._results(run), {"a-base": "done", "z-kid": "done"}) + deleted = [ + c.args[0][3] + for c in run.guard.git.call_args_list + if c.args[0][:3] == ["update-ref", "--no-deref", "-d"] + ] + self.assertEqual(deleted, ["refs/heads/z-kid", "refs/heads/a-base"]) + + def test_local_upstream_cycle_is_kept_on_apply(self) -> None: + self._set_head() + for name in ("c1", "c2"): + git(self.repo, "branch", name, "done") + for name, upstream in (("c1", "c2"), ("c2", "c1")): + git(self.repo, "config", f"branch.{name}.remote", ".") + git(self.repo, "config", f"branch.{name}.merge", f"refs/heads/{upstream}") + + run = self._cleanup("local", apply=True, include=("c1", "c2")) + + self.assertEqual( + self._results(run), + { + name: f"skipped: changed since plan (kept: local upstream of {other})" + for name, other in (("c1", "c2"), ("c2", "c1")) + }, + ) + heads = self._refs(self.repo, "refs/heads") + self.assertIn("refs/heads/c1", heads) + self.assertIn("refs/heads/c2", heads) + + def _hub_call(self, *branches: str) -> SimpleNamespace: + """ + Args: + branches: the --branch values of `branch cleanup --local --apply + --json`, run in the fixture repository under _guarded with a + compose stack listed in the held worktree. + + Returns: + The exit code, stdout, stderr, the _guarded spies and the + container listing and docker mocks, as attributes code, out, + err, guard, compose and docker. + """ + argv = ["branch", "cleanup", "--local", "--apply", "--json"] + args = create_parser("test").parse_args( + [*argv, *(f"--branch={name}" for name in branches)] + ) + ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []}) + stack = [gc.Container("proj", os.path.join(self.held, "app"), "img")] + os.chdir(self.repo) + out, err = io.StringIO(), io.StringIO() + with ( + self._guarded() as guard, + patch.object(gc, "list_compose_containers", return_value=stack) as ps, + patch.object(gc, "_docker", side_effect=AssertionError) as docker, + redirect_stdout(out), + redirect_stderr(err), + self.assertRaises(SystemExit) as stop, + ): + handle_branch(args, ctx=ctx) + return SimpleNamespace( + code=stop.exception.code, + out=out.getvalue(), + err=err.getvalue(), + guard=guard, + compose=ps, + docker=docker, + ) + + def test_hub_call_deletes_only_a_merged_branch_no_worktree_holds(self) -> None: + self._set_head() + heads = self._refs(self.repo, "refs/heads") + tracking = self._refs(self.repo, "refs/remotes") + origin = self._refs(self.origin, "refs") + trees = git(self.repo, "worktree", "list", "--porcelain") + + refused = self._hub_call("remote-done") + run = self._hub_call("held-done", "wip", "local-done") + + self.assertEqual(refused.code, 2) + self.assertEqual(refused.out, "") + self.assertIn("Unknown --branch: remote-done", refused.err) + self.assertEqual(run.code, 0) + items = {i["branch"]: i for i in json.loads(run.out)["repos"][0]["items"]} + self.assertEqual(set(items), {"held-done", "wip", "local-done"}) + held = items["held-done"] + self.assertEqual(held["skip"], f"checked out in worktree {self.held}") + self.assertEqual(held["worktree"], self.held) + self.assertEqual(held["compose_projects"], []) + self.assertEqual(items["wip"]["state"], "unmerged") + for name in ("held-done", "wip"): + with self.subTest(keeps=name): + self.assertEqual(items[name]["actions"], []) + self.assertIsNone(items[name]["result"]) + self.assertEqual(items["local-done"]["result"], "done") + after = self._refs(self.repo, "refs/heads") + self.assertEqual(set(heads) - set(after), {"refs/heads/local-done"}) + self.assertEqual(self._refs(self.repo, "refs/remotes"), tracking) + self.assertEqual(self._refs(self.origin, "refs"), origin) + self.assertEqual(git(self.repo, "worktree", "list", "--porcelain"), trees) + self.assertTrue(os.path.isfile(os.path.join(self.held, "done.txt"))) + for call in (refused, run): + call.compose.assert_not_called() + call.docker.assert_not_called() + self.assertLocalOnly(call.guard) + def test_branch_scope_still_needs_the_forge_for_a_remote_copy(self) -> None: run = self._cleanup("branch", apply=False) diff --git a/tests/integration/test_branch_gc.py b/tests/integration/test_branch_gc.py index ad6309a..46c1f5f 100644 --- a/tests/integration/test_branch_gc.py +++ b/tests/integration/test_branch_gc.py @@ -17,8 +17,7 @@ from pkgmgr.cli.parser import create_parser from pkgmgr.core.credentials.types import NoCredentialsError GITHUB_URL = "https://github.com/example/demo.git" -GIT_ENV = { - **os.environ, +AUTHOR = { "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.org", "GIT_COMMITTER_NAME": "t", @@ -30,7 +29,7 @@ def git(cwd: str, *args: str) -> str: return subprocess.run( ["git", "-c", "commit.gpgsign=false", *args], cwd=cwd, - env=GIT_ENV, + env={**os.environ, **AUTHOR}, check=True, capture_output=True, text=True, @@ -48,7 +47,16 @@ def docker_available() -> bool: class GcFixture: def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) self.root = os.path.realpath(self._tmp.name) + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(self.root) + home = os.path.join(self.root, "home") + os.makedirs(home) + scratch = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + patcher = patch.dict(os.environ, {**scratch, "HOME": home}, clear=True) + patcher.start() + self.addCleanup(patcher.stop) 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) @@ -83,9 +91,6 @@ class GcFixture: 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)