fix(branch): cleanup --local keeps branches whose sibling upstream was deleted

A local branch whose upstream sibling was deleted on the remote counted as merged when its tip was reachable from the deleted ref's last known commit, so cleanup --local removed it although git branch -d would refuse. The plan now asks git itself for each candidate, in the same order the delete runs, and skips a branch git would not delete; the README states the rule.

🎯 Fix: 93 %
💥 Break: 3 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 90
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-13 16:10:50 +00:00
1 parent 0d88c69c51
commit 3936125633
4 files changed
+341 -44

No files matched your search

+1 -1
View File
@@ -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 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 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 [--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.<name>` section in the repository's own config file (`git config --file <git common dir>/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_<n>` and `GIT_CONFIG_VALUE_<n>`, 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/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, 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 `<common>/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 `<common>/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/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<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.<name>.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 `<common>/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 seeLine truncated | `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.<name>` section in the repository's own config file (`git config --file <git common dir>/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_<n>` and `GIT_CONFIG_VALUE_<n>`, 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/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, 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 `<common>/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 `<common>/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/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<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.<name>.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 `<common>/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 itLine truncated
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching | | `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 [--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 | | `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 |
+98 -36
View File
@@ -8,6 +8,7 @@ import sys
import time import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import asdict, dataclass, field 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.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
from pkgmgr.core.git.errors import GitBaseError, GitRunError 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 chosen: the --branch and --exclude filter; only an eligible chosen
item is deleted, every other branch stays. item is deleted, every other branch stays.
""" """
reasons = {
name: f"kept: branch.{name}.deleteMerged is false" for name in repo.opted_out
}
pending = { pending = {
item.branch: item for item in repo.items if item.eligible and chosen(item) 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.<name>.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: while True:
for child, parent in sorted(repo.upstreams): for child, parent in sorted(upstreams):
if child not in pending: if child not in leaving:
reasons.setdefault(parent, f"kept: local upstream of {child}") reasons.setdefault(parent, f"kept: local upstream of {child}")
kept = reasons.keys() & pending.keys() kept = reasons.keys() & leaving
if not kept: if not kept:
return return {name: reasons[name] for name in pending - leaving}
for name in kept: leaving -= kept
item = pending.pop(name)
item.actions = []
item.skip = item.skip or reasons[name]
def _pins( def _pins(
@@ -1638,20 +1656,7 @@ def execute(repo: Repo, item: Item) -> None:
notes.append(f"image kept: {image}") notes.append(f"image kept: {image}")
if removes_tree: if removes_tree:
_remove_worktree(repo.repo, item.worktree) _remove_worktree(repo.repo, item.worktree)
if "local-branch" in item.actions and repo.scope == "local": if "local-branch" in item.actions:
_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:
_git(["branch", "-D", "--", item.branch], repo.repo) _git(["branch", "-D", "--", item.branch], repo.repo)
for remote, sha in item.remotes.items(): for remote, sha in item.remotes.items():
if f"remote-branch:{remote}" not in item.actions: 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 "" 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: def _apply(repo: Repo, item: Item) -> None:
""" """
Execute one planned item; under scope `local` without raising. 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}" item.result = f"skipped: {gone}"
return return
try: try:
execute(repo, item) _delete_local(repo, item)
except (RuntimeError, OSError, ValueError) as exc: except (RuntimeError, OSError, ValueError) as exc:
item.result = f"failed: {_describe(exc)}" item.result = f"failed: {_describe(exc)}"
@@ -1893,8 +1952,11 @@ def cleanup(
merged branches without a worktree, local and remote; `all` does merged branches without a worktree, local and remote; `all` does
both in one pass; `local` deletes, with `update-ref -d` against both in one pass; `local` deletes, with `update-ref -d` against
the planned sha, each local branch that no worktree, rebase or the planned sha, each local branch that no worktree, rebase or
bisect holds, that _keep_by_config does not keep and whose tip bisect holds, that _keep_by_config does not keep, both re-checked
is an ancestor of the remote-tracking 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 default or a merged_into line, refuses a repository without such
a default, and reads no forge token, runs no fetch, push, docker, a default, and reads no forge token, runs no fetch, push, docker,
hook or prune. hook or prune.
@@ -2001,14 +2063,14 @@ def _run(
if apply: if apply:
for repo in repos: for repo in repos:
for item in repo.items: todo = [item for item in repo.items if item.eligible and chosen(item)]
if item.eligible and chosen(item): for item in _delete_order(repo, todo) if repo.scope == "local" else todo:
_apply(repo, item) _apply(repo, item)
print( print(
f"[{options['scope']}] {item.branch or item.worktree}: " f"[{options['scope']}] {item.branch or item.worktree}: "
f"{item.result}", f"{item.result}",
file=sys.stderr, file=sys.stderr,
) )
_render(repos, host_ns, chosen, as_json) _render(repos, host_ns, chosen, as_json)
return _exit_code(repos, apply) return _exit_code(repos, apply)
+231 -1
View File
@@ -8,7 +8,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from collections.abc import Iterator from collections.abc import Callable, Iterator
from contextlib import contextmanager, redirect_stderr, redirect_stdout from contextlib import contextmanager, redirect_stderr, redirect_stdout
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
@@ -74,6 +74,14 @@ class TestBranchCleanupLocal(unittest.TestCase):
self.root = os.path.realpath(self._tmp.name) self.root = os.path.realpath(self._tmp.name)
self.addCleanup(os.chdir, os.getcwd()) self.addCleanup(os.chdir, os.getcwd())
os.chdir(self.root) 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.origin = os.path.join(self.root, "origin.git")
self.repo = os.path.join(self.root, "repo") self.repo = os.path.join(self.root, "repo")
git(self.root, "init", "--bare", "-b", "main", self.origin) 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.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertFalse(os.path.exists(marker)) 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: def test_branch_scope_still_needs_the_forge_for_a_remote_copy(self) -> None:
run = self._cleanup("branch", apply=False) run = self._cleanup("branch", apply=False)
+11 -6
View File
@@ -17,8 +17,7 @@ from pkgmgr.cli.parser import create_parser
from pkgmgr.core.credentials.types import NoCredentialsError from pkgmgr.core.credentials.types import NoCredentialsError
GITHUB_URL = "https://github.com/example/demo.git" GITHUB_URL = "https://github.com/example/demo.git"
GIT_ENV = { AUTHOR = {
**os.environ,
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org", "GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_NAME": "t",
@@ -30,7 +29,7 @@ def git(cwd: str, *args: str) -> str:
return subprocess.run( return subprocess.run(
["git", "-c", "commit.gpgsign=false", *args], ["git", "-c", "commit.gpgsign=false", *args],
cwd=cwd, cwd=cwd,
env=GIT_ENV, env={**os.environ, **AUTHOR},
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -48,7 +47,16 @@ def docker_available() -> bool:
class GcFixture: class GcFixture:
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = os.path.realpath(self._tmp.name) 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.origin = os.path.join(self.root, "origin.git")
self.repo = os.path.join(self.root, "repo") self.repo = os.path.join(self.root, "repo")
git(self.root, "init", "--bare", "-b", "main", self.origin) 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} merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
) )
def tearDown(self) -> None:
self._tmp.cleanup()
def _commit(self, name: str) -> None: def _commit(self, name: str) -> None:
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh: with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
fh.write(name) fh.write(name)