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 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.<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 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 |
+98 -36
View File
@@ -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.<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:
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)
+231 -1
View File
@@ -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)
+11 -6
View File
@@ -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)