Files
pkgmgr/tests/integration/test_branch_cleanup_local.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 3936125633 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>
2026-11-13 16:10:50 +00:00

1966 lines
81 KiB
Python

from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from collections.abc import Callable, Iterator
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.actions.branch import gc, gc_forge, tree
from pkgmgr.cli.commands.branch import handle_branch, scan_repo_roots
from pkgmgr.cli.parser import create_parser
from pkgmgr.core.credentials.types import NoCredentialsError
from pkgmgr.core.git.errors import GitBaseError, GitRunError
from pkgmgr.core.git.run import CALLER_CONFIG, DEFAULT_CONFIG
GITHUB_URL = "https://github.com/example/demo.git"
AUTHOR = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.org",
}
FAILING_DOCKER = """#!{python}
import json, os, sys
with open(os.environ["FAKE_DOCKER_LOG"], "a", encoding="utf-8") as fh:
fh.write(json.dumps(sys.argv[1:]) + "\\n")
sys.exit(1)
"""
MARKING_HOOK = """#!/bin/sh
echo "$@" >> "{marker}"
"""
BRANCH_CLI = """import sys
from types import SimpleNamespace
from pkgmgr.cli.commands.branch import handle_branch
from pkgmgr.cli.parser import create_parser
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
handle_branch(create_parser("test").parse_args(sys.argv[1:]), ctx=ctx)
"""
def clean_env() -> dict[str, str]:
"""
Build the environment of every git the tests run.
Returns:
The current environment without any `GIT_*` variable, plus a fixed
author and committer.
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
return {**env, **AUTHOR}
def git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", "-c", "commit.gpgsign=false", *args],
cwd=cwd,
env=clean_env(),
check=True,
capture_output=True,
text=True,
).stdout.strip()
class TestBranchCleanupLocal(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
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)
git(self.root, "init", "-b", "main", self.repo)
git(self.repo, "remote", "add", "origin", f"file://{self.origin}")
self._commit("init")
git(self.repo, "push", "-u", "origin", "main")
git(self.repo, "checkout", "-b", "done", "main")
self._commit("done")
git(self.repo, "push", "-u", "origin", "done")
git(self.repo, "push", "origin", "done:refs/heads/remote-done")
git(self.repo, "checkout", "-b", "picked", "main")
self._commit("picked")
git(self.repo, "checkout", "main")
git(self.repo, "merge", "--no-ff", "-m", "merge done", "done")
git(self.repo, "cherry-pick", "picked")
git(self.repo, "push", "origin", "main")
git(self.repo, "checkout", "-b", "wip", "main")
self._commit("wip")
git(self.repo, "checkout", "main")
git(self.repo, "fetch", "origin")
git(self.repo, "update-ref", "--no-deref", "-d", "refs/remotes/origin/HEAD")
git(self.repo, "branch", "local-done", "done")
git(self.repo, "branch", "held-done", "done")
self.held = os.path.join(self.root, "wt-held")
git(self.repo, "worktree", "add", self.held, "held-done")
git(self.repo, "remote", "add", "hub", GITHUB_URL)
self.bin = os.path.join(self.root, "bin")
os.makedirs(self.bin)
docker = os.path.join(self.bin, "docker")
with open(docker, "w", encoding="utf-8") as fh:
fh.write(FAILING_DOCKER.format(python=sys.executable))
os.chmod(docker, 0o755)
self.docker_log = os.path.join(self.root, "docker.log")
def tearDown(self) -> None:
self._tmp.cleanup()
def _set_head(self) -> None:
git(self.repo, "remote", "set-head", "origin", "main")
def _commit(self, name: str) -> None:
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
fh.write(name)
git(self.repo, "add", f"{name}.txt")
git(self.repo, "commit", "-m", name)
def _refs(self, cwd: str, prefix: str) -> dict[str, str]:
out = git(cwd, "for-each-ref", "--format=%(refname) %(objectname)", prefix)
return dict(line.split(" ") for line in out.splitlines())
@contextmanager
def _guarded(self) -> Iterator[SimpleNamespace]:
"""
Patch the host checks, drop every `GIT_*` variable from the
environment and watch every token, forge and docker access.
Returns:
The git run spy, the token resolver, HTTP client and Secret
Service mocks, as attributes git, token, http and secret.
"""
env = {
**clean_env(),
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
"FAKE_DOCKER_LOG": self.docker_log,
"GIT_ALLOW_PROTOCOL": "file",
}
with (
patch.dict(os.environ, env, clear=True),
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "_process_cwds", return_value={}),
patch.object(gc, "run", wraps=gc.run) as git_run,
patch.object(gc_forge, "TokenResolver") as resolver,
patch.object(gc_forge, "HttpClient") as http,
patch.object(
tree, "secret_service_token", return_value=(None, "test")
) as secret,
):
resolver.return_value.get_token.side_effect = NoCredentialsError("none")
yield SimpleNamespace(git=git_run, token=resolver, http=http, secret=secret)
def _cleanup(
self,
scope: str,
apply: bool,
merged_into: tuple[str, ...] = (),
roots: tuple[str, ...] = (),
leak: dict[str, str] | None = None,
include: tuple[str, ...] = (),
exclude: tuple[str, ...] = (),
) -> SimpleNamespace:
"""
Run gc.cleanup on the fixture under _guarded.
Args:
scope: the cleanup scope.
apply: execute the plan.
merged_into: integration lines that also count as merged.
roots: the repositories to scan; the fixture repository if empty.
leak: variables set in the environment gc.cleanup sees, after
_guarded dropped every `GIT_*` one.
include: the --branch filter.
exclude: the --exclude filter.
Returns:
The exit code, the first repository's items by branch and
warnings, every repository record, the stderr text and the
_guarded spies, as attributes code, items, warnings, repos, stderr
and guard; no repositories when nothing was printed.
"""
out, err = io.StringIO(), io.StringIO()
with (
self._guarded() as guard,
patch.dict(os.environ, leak or {}),
redirect_stdout(out),
redirect_stderr(err),
):
code = gc.cleanup(
list(roots) or [self.repo],
scope=scope,
apply=apply,
as_json=True,
include=list(include),
exclude=list(exclude),
merged_into=list(merged_into),
sizes=False,
min_age=None,
hooks={},
)
repos = json.loads(out.getvalue())["repos"] if out.getvalue() else []
first = repos[0] if repos else {"items": [], "warnings": []}
return SimpleNamespace(
code=code,
items={i["branch"]: i for i in first["items"]},
warnings=first["warnings"],
repos=repos,
stderr=err.getvalue(),
guard=guard,
)
def _branch_config(self, name: str) -> list[str]:
return subprocess.run(
["git", "config", "--get-regexp", f"^branch\\.{name}\\."],
cwd=self.repo,
env=clean_env(),
check=False,
capture_output=True,
text=True,
).stdout.split()[::2]
def _results(self, run: SimpleNamespace) -> dict[str, str]:
return {name: i["result"] for name, i in run.items.items() if i["result"]}
def assertLocalOnly(self, guard: SimpleNamespace) -> None:
self.assertFalse(os.path.exists(self.docker_log))
guard.token.assert_not_called()
guard.http.assert_not_called()
guard.secret.assert_not_called()
for call in guard.git.call_args_list:
with self.subTest(argv=call.args[0]):
self.assertNotIn("push", call.args[0])
self.assertNotIn("fetch", call.args[0])
def test_dry_run_plans_only_ancestor_tips(self) -> None:
self._set_head()
run = self._cleanup("local", apply=False)
items = run.items
self.assertEqual(run.code, 0)
self.assertEqual(run.warnings, [])
self.assertEqual(
set(items), {"main", "done", "local-done", "held-done", "picked", "wip"}
)
self.assertEqual(items["done"]["upstream"], "origin/done")
self.assertEqual(list(items["done"]["remotes"]), ["origin"])
for name in ("done", "local-done"):
with self.subTest(deletes=name):
self.assertIsNone(items[name]["skip"])
self.assertEqual(items[name]["actions"], ["local-branch"])
self.assertEqual(
items["held-done"]["skip"], f"checked out in worktree {self.held}"
)
self.assertEqual(
items["picked"]["skip"],
"merged by patch, tip is no ancestor of refs/remotes/origin/main",
)
self.assertEqual(items["wip"]["state"], "unmerged")
self.assertEqual(items["main"]["state"], "protected")
for name in ("held-done", "picked", "wip", "main"):
with self.subTest(keeps=name):
self.assertEqual(items[name]["actions"], [])
self.assertLocalOnly(run.guard)
def test_apply_deletes_only_local_refs_against_the_planned_sha(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")
self.assertEqual(
self._branch_config("done"), ["branch.done.remote", "branch.done.merge"]
)
self.assertEqual(self._branch_config("local-done"), [])
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 0)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(self._branch_config("done"), [])
self.assertEqual(
self._branch_config("main"), ["branch.main.remote", "branch.main.merge"]
)
after = self._refs(self.repo, "refs/heads")
self.assertEqual(
set(heads) - set(after), {"refs/heads/done", "refs/heads/local-done"}
)
self.assertEqual(set(after) - set(heads), set())
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)
deletes = [c.args[0] for c in run.guard.git.call_args_list if "-d" in c.args[0]]
self.assertEqual(
deletes,
[
[
"update-ref",
"--no-deref",
"-d",
f"refs/heads/{name}",
heads[f"refs/heads/{name}"],
]
for name in ("done", "local-done")
],
)
self.assertLocalOnly(run.guard)
def test_branch_moved_after_the_check_is_not_deleted(self) -> None:
planned = git(self.repo, "rev-parse", "done")
moved = git(
self.repo,
"commit-tree",
"-p",
planned,
"-m",
"moved",
f"{planned}^{{tree}}",
)
def race(_repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "done":
git(self.repo, "update-ref", "refs/heads/done", moved)
return item
with patch.object(gc, "_rebuild", side_effect=race):
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 1)
self.assertTrue(run.items["done"]["result"].startswith("failed: "))
self.assertEqual(run.items["local-done"]["result"], "done")
self.assertEqual(git(self.repo, "rev-parse", "refs/heads/done"), moved)
def test_cherry_picked_branch_stays_with_only_a_file_remote(self) -> None:
git(self.repo, "remote", "remove", "hub")
run = self._cleanup("local", apply=True)
self.assertEqual(run.items["picked"]["state"], "merged-clean")
self.assertEqual(run.items["picked"]["merged_by"], "patch")
self.assertEqual(
run.items["picked"]["skip"],
"merged by patch, tip is no ancestor of refs/remotes/origin/main",
)
self.assertNotIn("picked", self._results(run))
self.assertIn("refs/heads/picked", self._refs(self.repo, "refs/heads"))
def test_target_is_the_default_ref_not_head(self) -> None:
init = git(self.repo, "rev-list", "--max-parents=0", "main")
git(self.repo, "checkout", "-b", "head-only", init)
self._commit("head-only")
git(self.repo, "checkout", "--detach", init)
git(self.repo, "merge", "--no-ff", "-m", "merge head-only", "head-only")
self.assertNotEqual(
git(self.repo, "rev-parse", "HEAD"),
git(self.repo, "rev-parse", "origin/main"),
)
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(run.items["head-only"]["state"], "unmerged")
self.assertIn("refs/heads/head-only", self._refs(self.repo, "refs/heads"))
def test_merged_into_line_counts_only_when_given(self) -> None:
git(self.repo, "checkout", "-b", "line-only", "main")
self._commit("line-only")
git(self.repo, "checkout", "-b", "staging", "main")
git(self.repo, "merge", "--no-ff", "-m", "merge line-only", "line-only")
git(self.repo, "push", "origin", "staging")
git(self.repo, "checkout", "main")
git(self.repo, "branch", "-D", "staging")
without = self._cleanup("local", apply=False)
run = self._cleanup("local", apply=True, merged_into=("origin/staging",))
self.assertEqual(without.items["line-only"]["state"], "unmerged")
self.assertEqual(without.items["line-only"]["actions"], [])
self.assertEqual(run.items["line-only"]["merged_by"], "line:origin/staging")
self.assertEqual(run.items["line-only"]["result"], "done")
self.assertNotIn("refs/heads/line-only", self._refs(self.repo, "refs/heads"))
self.assertLocalOnly(run.guard)
def test_without_remote_head_a_local_main_merge_counts_for_nothing(self) -> None:
git(self.repo, "checkout", "-b", "feat", "main")
self._commit("feat")
git(self.repo, "checkout", "main")
git(self.repo, "merge", "--no-ff", "-m", "merge feat", "feat")
head = subprocess.run(
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
cwd=self.repo,
env=clean_env(),
check=False,
capture_output=True,
)
self.assertNotEqual(head.returncode, 0)
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 0)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(run.items["feat"]["state"], "unmerged")
self.assertIn("refs/heads/feat", self._refs(self.repo, "refs/heads"))
def test_without_remote_default_the_repository_is_refused(self) -> None:
git(self.repo, "update-ref", "-d", "refs/remotes/origin/main")
git(self.repo, "branch", "-m", "main", "trunk")
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True, merged_into=("origin/done",))
self.assertEqual(run.code, 2)
self.assertEqual(run.warnings, [gc.NO_REMOTE_DEFAULT])
self.assertEqual(run.items, {})
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
self.assertLocalOnly(run.guard)
def test_rebase_and_bisect_hold_their_branches(self) -> None:
init = git(self.repo, "rev-list", "--max-parents=0", "main")
rebasing = os.path.join(self.root, "wt-rebase")
git(self.repo, "worktree", "add", rebasing, "local-done")
subprocess.run(
["git", "rebase", "-f", "--exec", "false", "HEAD~1"],
cwd=rebasing,
env=clean_env(),
check=False,
capture_output=True,
)
bisecting = os.path.join(self.root, "wt-bisect")
git(self.repo, "worktree", "add", bisecting, "done")
git(bisecting, "bisect", "start", "done", init)
git(bisecting, "checkout", "--detach")
for path, state in ((rebasing, "rebase-merge"), (bisecting, "BISECT_START")):
with self.subTest(precondition=state):
state_path = git(path, "rev-parse", "--git-path", state)
self.assertTrue(os.path.exists(os.path.join(path, state_path)))
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 0)
self.assertEqual(self._results(run), {})
for name, operation in (("local-done", "rebase"), ("done", "bisect")):
with self.subTest(held=name):
self.assertIsNone(run.items[name]["worktree"])
self.assertTrue(
run.items[name]["skip"].startswith(f"{operation} in progress in ")
)
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def _local_merge(self, branch: str) -> None:
git(self.repo, "checkout", "-b", branch, "main")
self._commit(branch)
git(self.repo, "checkout", "main")
git(self.repo, "merge", "--no-ff", "-m", f"merge {branch}", branch)
def test_symbolic_ref_and_its_target_are_held(self) -> None:
git(self.repo, "symbolic-ref", "refs/heads/alias", "refs/heads/local-done")
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done"})
self.assertEqual(
run.items["alias"]["skip"], "symbolic ref to refs/heads/local-done"
)
self.assertEqual(
run.items["local-done"]["skip"], "target of symbolic ref refs/heads/alias"
)
self.assertEqual(
git(self.repo, "symbolic-ref", "refs/heads/alias"), "refs/heads/local-done"
)
self.assertEqual(
self._refs(self.repo, "refs/heads")["refs/heads/local-done"],
heads["refs/heads/local-done"],
)
def test_delete_never_follows_a_symref_made_after_the_check(self) -> None:
planned = git(self.repo, "rev-parse", "held-done")
def race(_repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "done":
git(self.repo, "update-ref", "-d", "refs/heads/done")
git(
self.repo, "symbolic-ref", "refs/heads/done", "refs/heads/held-done"
)
return item
with patch.object(gc, "_rebuild", side_effect=race):
self._cleanup("local", apply=True)
self.assertEqual(git(self.repo, "rev-parse", "refs/heads/held-done"), planned)
self.assertEqual(git(self.held, "symbolic-ref", "HEAD"), "refs/heads/held-done")
def test_registration_and_state_files_hold_their_branches(self) -> None:
git(self.repo, "branch", "stacked", "done")
ghost = os.path.join(self.root, "wt-ghost")
git(self.repo, "worktree", "add", ghost, "local-done")
with open(os.path.join(ghost, "uncommitted.txt"), "w") as fh:
fh.write("work")
meta = os.path.join(self.repo, ".git", "worktrees")
os.remove(os.path.join(meta, "wt-ghost", "gitdir"))
sha = git(self.repo, "rev-parse", "stacked")
states = {
"rebase-apply/head-name": "refs/heads/done\n",
"rebase-merge/update-refs": f"refs/heads/stacked\n{sha}\n{sha}\n",
}
for name, content in states.items():
path = os.path.join(meta, "wt-held", name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {})
self.assertIsNone(run.items["local-done"]["worktree"])
for name, reason in (
("local-done", "checked out in "),
("done", "rebase in progress in "),
("stacked", "rebase in progress in "),
):
with self.subTest(held=name):
self.assertTrue(run.items[name]["skip"].startswith(reason))
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
self.assertTrue(os.path.isfile(os.path.join(ghost, "uncommitted.txt")))
def test_remote_head_selects_the_default_over_main(self) -> None:
init = git(self.repo, "rev-list", "--max-parents=0", "main")
git(self.repo, "checkout", "-b", "on-trunk", init)
self._commit("on-trunk")
git(self.repo, "checkout", "-b", "trunk", init)
git(self.repo, "merge", "--no-ff", "-m", "merge on-trunk", "on-trunk")
git(self.repo, "push", "origin", "trunk")
git(self.repo, "checkout", "main")
git(self.repo, "branch", "-D", "trunk")
git(
self.repo,
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/trunk",
)
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"on-trunk": "done"})
self.assertEqual(run.items["done"]["state"], "unmerged")
def test_remote_head_into_a_local_branch_is_ignored(self) -> None:
self._local_merge("feat")
git(self.repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/main")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(run.items["feat"]["state"], "unmerged")
self.assertIn("refs/heads/feat", self._refs(self.repo, "refs/heads"))
def test_remote_main_into_a_local_branch_is_refused(self) -> None:
self._local_merge("feat")
git(self.repo, "update-ref", "-d", "refs/remotes/origin/main")
git(self.repo, "symbolic-ref", "refs/remotes/origin/main", "refs/heads/main")
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertEqual(run.warnings, [gc.NO_REMOTE_DEFAULT])
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def _fork_main(self, merged: str) -> None:
init = git(self.repo, "rev-list", "--max-parents=0", "main")
git(self.repo, "checkout", "-b", merged, init)
self._commit(merged)
git(self.repo, "checkout", "--detach", init)
git(self.repo, "merge", "--no-ff", "-m", f"merge {merged}", merged)
sha = git(self.repo, "rev-parse", "HEAD")
git(self.repo, "checkout", "main")
git(self.repo, "remote", "add", "fork", "file:///nonexistent/fork.git")
git(self.repo, "update-ref", "refs/remotes/fork/main", sha)
git(
self.repo,
"symbolic-ref",
"refs/remotes/fork/HEAD",
"refs/remotes/fork/main",
)
def test_origin_wins_over_another_remote_head(self) -> None:
self._fork_main("feat4")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(run.items["feat4"]["state"], "unmerged")
def test_several_remotes_without_origin_are_refused(self) -> None:
self._fork_main("feat4")
git(self.repo, "remote", "remove", "hub")
git(self.repo, "remote", "rename", "origin", "up")
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertEqual(run.warnings, [gc.NO_REMOTE_DEFAULT])
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def _raw_heads(self, cwd: str) -> bytes:
return subprocess.run(
["git", "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads"],
cwd=cwd,
env=clean_env(),
check=True,
capture_output=True,
).stdout
def test_submodule_cleanup_stays_in_the_submodule(self) -> None:
sub_origin = os.path.join(self.root, "sub-origin.git")
seed = os.path.join(self.root, "seed")
git(self.root, "init", "--bare", "-b", "main", sub_origin)
git(self.root, "init", "-b", "main", seed)
with open(os.path.join(seed, "s.txt"), "w") as fh:
fh.write("s")
git(seed, "add", "s.txt")
git(seed, "commit", "-m", "s")
git(seed, "push", f"file://{sub_origin}", "main")
git(
self.repo,
"-c",
"protocol.file.allow=always",
"submodule",
"add",
f"file://{sub_origin}",
"sub",
)
sub = os.path.join(self.repo, "sub")
heads = self._refs(self.repo, "refs/heads")
args = create_parser("test").parse_args(
["branch", "cleanup", "--local", "--apply", "--json"]
)
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
os.chdir(sub)
out = io.StringIO()
with (
self._guarded(),
redirect_stdout(out),
redirect_stderr(io.StringIO()),
self.assertRaises(SystemExit),
):
handle_branch(args, ctx=ctx)
self.assertEqual(gc.local_repo_root(sub), sub)
self.assertEqual(json.loads(out.getvalue())["repos"][0]["repo"], sub)
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_main_worktree_of_another_repository_is_refused(self) -> None:
other = os.path.join(self.root, "other")
git(self.root, "init", "-b", "main", other)
git(other, "config", "core.worktree", self.repo)
with self.assertRaises(GitBaseError):
gc.local_repo_root(other)
def test_core_worktree_shape_keeps_the_head_root_outside_local(self) -> None:
parent = os.path.join(self.root, "cwt-a")
plain = os.path.join(self.root, "cwt-b")
git(self.root, "init", "-b", "main", parent)
os.makedirs(plain)
git(parent, "config", "core.worktree", plain)
parse = create_parser("test").parse_args
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
os.chdir(parent)
self.assertEqual(gc.main_repo_root("."), parent)
self.assertEqual(scan_repo_roots(parse(["branch", "cleanup"]), ctx), [parent])
local = parse(["branch", "cleanup", "--local"])
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as stop:
scan_repo_roots(local, ctx, local=True)
self.assertEqual(stop.exception.code, 2)
def test_non_utf8_repository_path_is_refused(self) -> None:
bad = os.fsdecode(os.path.join(os.fsencode(self.root), b"bad\xffdir"))
subprocess.run(
["git", "clone", f"file://{self.origin}", bad],
env=clean_env(),
check=True,
capture_output=True,
)
before = self._raw_heads(bad)
args = create_parser("test").parse_args(
["branch", "cleanup", "--local", "--apply", "--json"]
)
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
os.chdir(bad)
err = io.StringIO()
with (
self._guarded(),
redirect_stdout(io.StringIO()),
redirect_stderr(err),
self.assertRaises(SystemExit) as stop,
):
handle_branch(args, ctx=ctx)
self.assertEqual(stop.exception.code, 2)
self.assertIn(gc.NOT_UTF8, err.getvalue())
self.assertEqual(self._raw_heads(bad), before)
def test_reftable_registration_head_holds_its_branch(self) -> None:
rt = os.path.join(self.root, "rt")
made = subprocess.run(
["git", "init", "-b", "main", "--ref-format=reftable", rt],
env=clean_env(),
check=False,
capture_output=True,
)
if made.returncode:
self.skipTest("git without reftable support")
git(rt, "remote", "add", "origin", f"file://{self.origin}")
git(rt, "fetch", "origin")
git(rt, "branch", "feat", "origin/done")
ghost = os.path.join(self.root, "rt-ghost")
git(rt, "worktree", "add", ghost, "feat")
os.remove(os.path.join(rt, ".git", "worktrees", "rt-ghost", "gitdir"))
run = self._cleanup("local", apply=True, roots=(rt,))
self.assertIsNone(run.items["feat"]["worktree"])
self.assertTrue(run.items["feat"]["skip"].startswith("checked out in "))
self.assertIn("refs/heads/feat", self._refs(rt, "refs/heads"))
def test_merged_into_branch_target_is_never_deleted(self) -> None:
git(self.repo, "branch", "a-line", "done")
run = self._cleanup("local", apply=True, merged_into=("refs/heads/a-line",))
self.assertEqual(run.items["a-line"]["state"], "protected")
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertIn("refs/heads/a-line", self._refs(self.repo, "refs/heads"))
def test_vanished_target_skips_the_rest_and_still_reports(self) -> None:
git(self.repo, "checkout", "-b", "line-only", "main")
self._commit("line-only")
git(self.repo, "checkout", "-b", "staging", "main")
git(self.repo, "merge", "--no-ff", "-m", "merge line-only", "line-only")
git(self.repo, "push", "origin", "staging")
git(self.repo, "checkout", "main")
git(self.repo, "branch", "-D", "staging")
rebuild = gc._rebuild
def vanish(repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "line-only":
git(self.repo, "update-ref", "-d", "refs/remotes/origin/staging")
return rebuild(repo, item)
with patch.object(gc, "_rebuild", side_effect=vanish):
run = self._cleanup("local", apply=True, merged_into=("origin/staging",))
self.assertEqual(run.code, 1)
self.assertEqual(run.items["done"]["result"], "done")
self.assertTrue(run.items["line-only"]["result"].startswith("failed: "))
self.assertEqual(
run.items["local-done"]["result"],
"skipped: target origin/staging is gone",
)
heads = self._refs(self.repo, "refs/heads")
self.assertIn("refs/heads/line-only", heads)
self.assertIn("refs/heads/local-done", heads)
def test_failed_item_outranks_a_refused_repository(self) -> None:
other = os.path.join(self.root, "other")
git(self.root, "init", "-b", "main", other)
moved = git(
self.repo, "commit-tree", "-p", "done", "-m", "moved", "done^{tree}"
)
def race(_repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "done":
git(self.repo, "update-ref", "refs/heads/done", moved)
return item
with patch.object(gc, "_rebuild", side_effect=race):
run = self._cleanup("local", apply=True, roots=(self.repo, other))
self.assertEqual(run.code, 1)
self.assertTrue(run.items["done"]["result"].startswith("failed: "))
self.assertEqual(run.items["local-done"]["result"], "done")
self.assertEqual(run.repos[1]["warnings"], [gc.NO_REMOTE_DEFAULT])
def test_non_utf8_ref_name_refuses_the_repository(self) -> None:
git(self.repo, "branch", os.fsdecode(b"bad\xff"), "done")
before = self._raw_heads(self.repo)
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertEqual(run.warnings, [gc.NOT_UTF8])
self.assertEqual(run.items, {})
self.assertEqual(self._raw_heads(self.repo), before)
def test_refs_shared_through_a_symlink_refuse_the_repository(self) -> None:
git(self.repo, "checkout", "local-done")
shared = os.path.join(self.root, "new")
os.makedirs(os.path.join(shared, ".git", "logs"))
for name in ("config", "refs", "objects", "info", "hooks", "packed-refs"):
source = os.path.join(self.repo, ".git", name)
if os.path.exists(source):
os.symlink(source, os.path.join(shared, ".git", name))
os.symlink(
os.path.join(self.repo, ".git", "logs", "refs"),
os.path.join(shared, ".git", "logs", "refs"),
)
with open(os.path.join(shared, ".git", "HEAD"), "w", encoding="utf-8") as fh:
fh.write("ref: refs/heads/main\n")
git(shared, "checkout", "-f", "main")
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True, roots=(shared,))
self.assertEqual(run.code, 2)
self.assertTrue(run.warnings[-1].startswith("refused: refs"))
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
self.assertEqual(
git(self.repo, "symbolic-ref", "HEAD"), "refs/heads/local-done"
)
def test_symrefs_outside_heads_hold_their_targets(self) -> None:
git(self.repo, "symbolic-ref", "refs/tags/latest", "refs/heads/done")
git(
self.repo,
"symbolic-ref",
"refs/remotes/origin/alias",
"refs/heads/local-done",
)
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {})
self.assertEqual(
run.items["done"]["skip"], "target of symbolic ref refs/tags/latest"
)
self.assertEqual(
run.items["local-done"]["skip"],
"target of symbolic ref refs/remotes/origin/alias",
)
heads = self._refs(self.repo, "refs/heads")
self.assertIn("refs/heads/done", heads)
self.assertIn("refs/heads/local-done", heads)
def _clone_with(self, path: str, *branches: str) -> None:
git(self.root, "clone", f"file://{self.origin}", path)
for name in branches:
git(path, "branch", name, "origin/done")
def test_vanished_repository_skips_its_rest_and_still_prints(self) -> None:
other = os.path.join(self.root, "other")
self._clone_with(other, "a-first", "local-done")
rebuild = gc._rebuild
def vanish(repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "a-first":
os.rename(other, f"{other}-moved")
return rebuild(repo, item)
with patch.object(gc, "_rebuild", side_effect=vanish):
run = self._cleanup("local", apply=True, roots=(other, self.repo))
self.assertEqual(run.code, 1)
self.assertTrue(run.items["a-first"]["result"].startswith("failed: "))
self.assertTrue(
run.items["local-done"]["result"].startswith(
f"skipped: repository {other} unreadable: "
)
)
mine = {i["branch"]: i["result"] for i in run.repos[1]["items"]}
self.assertEqual(mine["done"], "done")
self.assertIn(
"refs/heads/local-done", self._refs(f"{other}-moved", "refs/heads")
)
def test_repository_replaced_mid_run_is_never_touched(self) -> None:
nested = os.path.join(self.repo, "nested")
self._clone_with(nested, "a-first", "local-done")
rebuild = gc._rebuild
def replace(repo: gc.Repo, item: gc.Item) -> gc.Item:
if item.branch == "a-first":
shutil.rmtree(os.path.join(nested, ".git"))
return rebuild(repo, item)
with patch.object(gc, "_rebuild", side_effect=replace):
run = self._cleanup("local", apply=True, roots=(nested,))
self.assertEqual(
run.items["local-done"]["result"],
f"skipped: repository {nested} is no longer the planned one",
)
self.assertIn("refs/heads/local-done", self._refs(self.repo, "refs/heads"))
def test_non_local_plan_keeps_a_merged_into_branch_unprotected(self) -> None:
run = self._cleanup(
"branch", apply=False, merged_into=("refs/heads/local-done",)
)
self.assertEqual(run.items["local-done"]["state"], "merged-clean")
self.assertEqual(run.items["local-done"]["actions"], ["local-branch"])
def test_worktree_symref_holds_its_target(self) -> None:
pinned = os.path.join(self.root, "wt-pin")
git(self.repo, "worktree", "add", "-b", "pin-base", pinned, "main")
git(pinned, "symbolic-ref", "refs/worktree/pin", "refs/heads/local-done")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done"})
self.assertEqual(
run.items["local-done"]["skip"],
"target of symbolic ref worktrees/wt-pin/refs/worktree/pin",
)
self.assertIn("refs/heads/local-done", self._refs(self.repo, "refs/heads"))
def test_unreadable_repository_is_refused_and_the_run_goes_on(self) -> None:
stale = os.path.join(self.root, "stale")
os.makedirs(stale)
with open(os.path.join(stale, ".git"), "w", encoding="utf-8") as fh:
fh.write(f"gitdir: {self.root}/gone/.git/worktrees/stale\n")
run = self._cleanup("local", apply=True, roots=(self.repo, stale))
self.assertEqual(run.code, 2)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(run.repos[1]["items"], [])
self.assertTrue(run.repos[1]["warnings"][-1].startswith("refused: "))
def _state(self, cwd: str) -> tuple[str, str, bytes]:
with open(os.path.join(cwd, ".git", "config"), "rb") as fh:
config = fh.read()
refs = git(cwd, "for-each-ref", "--format=%(refname) %(objectname)")
return refs, git(cwd, "remote", "-v"), config
def test_git_dir_in_the_environment_never_reaches_a_repository(self) -> None:
sentinel = os.path.join(self.root, "sentinel")
git(self.root, "init", "-b", "trunk", sentinel)
git(sentinel, "commit", "--allow-empty", "-m", "sentinel")
before = self._state(sentinel)
dot_git = os.path.join(sentinel, ".git")
leak = {
"GIT_DIR": dot_git,
"GIT_COMMON_DIR": dot_git,
"GIT_WORK_TREE": sentinel,
}
with patch.dict(os.environ, leak):
git(self.repo, "branch", "leak-done", "done")
self._commit("leak")
run = self._cleanup("local", apply=True)
self.assertEqual(self._state(sentinel), before)
self.assertEqual(run.items["leak-done"]["result"], "done")
def test_the_only_remote_gives_the_default_without_origin(self) -> None:
git(self.repo, "remote", "remove", "hub")
git(self.repo, "remote", "rename", "origin", "up")
run = self._cleanup("local", apply=True)
self.assertEqual(run.repos[0]["default"], "refs/remotes/up/main")
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
def test_merged_into_remote_tracking_ref_protects_its_branch(self) -> None:
git(self.repo, "branch", "a-line", "done")
git(self.repo, "push", "origin", "a-line")
run = self._cleanup(
"local", apply=True, merged_into=("refs/remotes/origin/a-line",)
)
self.assertEqual(run.items["a-line"]["state"], "protected")
self.assertIn("refs/heads/a-line", self._refs(self.repo, "refs/heads"))
def test_reftable_shared_through_a_symlink_refuses_the_repository(self) -> None:
rt = os.path.join(self.root, "rt-shared")
made = subprocess.run(
["git", "init", "-b", "main", "--ref-format=reftable", rt],
env=clean_env(),
check=False,
capture_output=True,
)
if made.returncode:
self.skipTest("git without reftable support")
git(rt, "remote", "add", "origin", f"file://{self.origin}")
git(rt, "fetch", "origin")
git(rt, "branch", "feat", "origin/done")
store = os.path.join(self.root, "reftable-store")
os.rename(os.path.join(rt, ".git", "reftable"), store)
os.symlink(store, os.path.join(rt, ".git", "reftable"))
run = self._cleanup("local", apply=True, roots=(rt,))
self.assertEqual(run.code, 2)
self.assertTrue(run.warnings[-1].startswith("refused: reftable"))
self.assertIn("refs/heads/feat", self._refs(rt, "refs/heads"))
def test_main_worktree_rebase_and_bisect_hold_their_branches(self) -> None:
common = os.path.join(self.repo, ".git")
os.makedirs(os.path.join(common, "rebase-merge"))
with open(os.path.join(common, "rebase-merge", "head-name"), "w") as fh:
fh.write("refs/heads/local-done\n")
with open(os.path.join(common, "BISECT_START"), "w") as fh:
fh.write("done\n")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {})
self.assertEqual(
run.items["local-done"]["skip"], f"rebase in progress in {common}"
)
self.assertEqual(run.items["done"]["skip"], f"bisect in progress in {common}")
def test_remote_master_is_the_last_default(self) -> None:
git(self.repo, "update-ref", "refs/remotes/origin/master", "origin/main")
git(self.repo, "update-ref", "-d", "refs/remotes/origin/main")
run = self._cleanup("local", apply=True)
self.assertEqual(run.repos[0]["default"], "refs/remotes/origin/master")
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
def test_bare_repository_head_holds_its_branch(self) -> None:
bare = os.path.join(self.root, "bare.git")
git(self.root, "clone", "--bare", f"file://{self.origin}", bare)
git(
bare, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"
)
git(bare, "fetch", "origin")
git(bare, "symbolic-ref", "HEAD", "refs/heads/done")
run = self._cleanup("local", apply=True, roots=(bare,))
self.assertEqual(run.items["done"]["skip"], f"checked out in {bare}")
self.assertIn("refs/heads/done", self._refs(bare, "refs/heads"))
def test_linked_worktree_resolves_to_the_main_worktree(self) -> None:
self.assertEqual(gc.local_repo_root(self.held), self.repo)
def test_git_variables_never_reach_the_local_git_calls(self) -> None:
sentinel = os.path.join(self.root, "sentinel")
git(self.root, "init", "-b", "trunk", sentinel)
git(sentinel, "commit", "--allow-empty", "-m", "sentinel")
git(sentinel, "config", "branch.done.remote", "origin")
git(sentinel, "config", "branch.local-done.description", "keep me")
config = os.path.join(sentinel, ".git", "config")
with open(config, "rb") as fh:
before = fh.read()
leak = {
"GIT_CONFIG": config,
"GIT_OBJECT_DIRECTORY": os.path.join(sentinel, ".git", "objects"),
"GIT_NAMESPACE": "foo",
}
run = self._cleanup("local", apply=True, leak=leak)
self.assertEqual(run.code, 0)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
with open(config, "rb") as fh:
self.assertEqual(fh.read(), before)
self.assertEqual(self._branch_config("done"), [])
own = os.path.join(self.repo, ".git", "config")
self.assertIn(
["config", "--file", own, "--remove-section", "branch.done"],
[call.args[0] for call in run.guard.git.call_args_list],
)
def test_merged_into_must_name_exactly_one_branch(self) -> None:
forms = (
"picked~0",
"picked^0",
"picked@{0}",
git(self.repo, "rev-parse", "picked"),
)
heads = self._refs(self.repo, "refs/heads")
for form in forms:
with self.subTest(form=form):
run = self._cleanup("local", apply=True, merged_into=(form,))
self.assertEqual(run.code, 2)
self.assertEqual(run.repos, [])
self.assertIn(f"--merged-into {form} ", run.stderr)
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_registration_with_any_id_holds_its_branch(self) -> None:
ghost = os.path.join(self.root, "wt-ghost")
git(self.repo, "worktree", "add", ghost, "local-done")
with open(os.path.join(ghost, "work.txt"), "w") as fh:
fh.write("uncommitted")
meta = os.path.join(self.repo, ".git", "worktrees")
os.rename(os.path.join(meta, "wt-ghost"), os.path.join(meta, "wt ghost"))
os.remove(os.path.join(meta, "wt ghost", "gitdir"))
with open(os.path.join(ghost, ".git"), "w", encoding="utf-8") as fh:
fh.write(f"gitdir: {meta}/wt ghost\n")
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done"})
self.assertEqual(
run.items["local-done"]["skip"], f"checked out in {meta}/wt ghost"
)
self.assertIn("refs/heads/local-done", self._refs(self.repo, "refs/heads"))
self.assertTrue(os.path.isfile(os.path.join(ghost, "work.txt")))
def test_unreadable_registration_refuses_the_repository(self) -> None:
broken = os.path.join(self.repo, ".git", "worktrees", "broken")
os.makedirs(broken)
for name, content in (("HEAD", "garbage\n"), ("commondir", "../..\n")):
with open(os.path.join(broken, name), "w", encoding="utf-8") as fh:
fh.write(content)
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertEqual(run.items, {})
self.assertTrue(run.warnings[-1].startswith("refused: "))
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_unreadable_state_file_refuses_the_repository(self) -> None:
os.makedirs(os.path.join(self.repo, ".git", "BISECT_START"))
heads = self._refs(self.repo, "refs/heads")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertTrue(run.warnings[-1].startswith("refused: "))
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_main_worktree_symref_holds_with_a_linked_root(self) -> None:
git(self.repo, "symbolic-ref", "refs/worktree/pin", "refs/heads/local-done")
run = self._cleanup("local", apply=True, roots=(self.held,))
self.assertEqual(
run.items["local-done"]["skip"], "target of symbolic ref refs/worktree/pin"
)
self.assertIn("refs/heads/local-done", self._refs(self.repo, "refs/heads"))
def _cli_apply(
self, cwd: str, leak: dict[str, str] | None = None
) -> tuple[int, str, str]:
"""
Run `branch cleanup --local --apply --json` in cwd under _guarded.
Args:
cwd: the directory to run in.
leak: variables set in the environment the CLI sees, after
_guarded dropped every `GIT_*` one.
Returns:
The exit code, stdout and stderr.
"""
args = create_parser("test").parse_args(
["branch", "cleanup", "--local", "--apply", "--json"]
)
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
os.chdir(cwd)
out, err = io.StringIO(), io.StringIO()
with (
self._guarded(),
patch.dict(os.environ, leak or {}),
redirect_stdout(out),
redirect_stderr(err),
self.assertRaises(SystemExit) as stop,
):
handle_branch(args, ctx=ctx)
return stop.exception.code, out.getvalue(), err.getvalue()
def test_ceiling_stops_discovery_of_the_repository_above(self) -> None:
below = os.path.join(self.repo, "not-a-repo")
os.makedirs(below)
heads = self._refs(self.repo, "refs/heads")
code, out, err = self._cli_apply(
below, leak={"GIT_CEILING_DIRECTORIES": self.repo}
)
self.assertEqual(code, 2)
self.assertIn("Not inside a git repository", err)
self.assertEqual(out, "")
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_git_variables_reaching_the_cli_never_touch_another_repo(self) -> None:
git(self.repo, "remote", "remove", "hub")
sentinel = os.path.join(self.root, "sentinel")
git(self.root, "init", "-b", "trunk", sentinel)
git(sentinel, "commit", "--allow-empty", "-m", "sentinel")
before = self._state(sentinel)
dot_git = os.path.join(sentinel, ".git")
env = {
**clean_env(),
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
"FAKE_DOCKER_LOG": self.docker_log,
"GIT_ALLOW_PROTOCOL": "file",
"GIT_DIR": dot_git,
"GIT_WORK_TREE": sentinel,
"GIT_INDEX_FILE": os.path.join(dot_git, "index"),
}
for argv, extra in (
(["--json"], {}),
(["--local", "--json"], {"GIT_COMMON_DIR": dot_git}),
):
with self.subTest(argv=argv):
done = subprocess.run(
[sys.executable, "-c", BRANCH_CLI, "branch", "cleanup", *argv],
cwd=self.repo,
env={**env, **extra},
check=False,
capture_output=True,
text=True,
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(json.loads(done.stdout)["repos"][0]["repo"], self.repo)
self.assertEqual(self._state(sentinel), before)
def test_registration_outside_the_common_dir_refuses_the_repository(
self,
) -> None:
moved = os.path.join(self.root, "wt-moved")
git(self.repo, "worktree", "add", moved, "local-done")
with open(os.path.join(moved, "work.txt"), "w") as fh:
fh.write("uncommitted")
common = os.path.join(self.repo, ".git")
reg = os.path.join(common, "worktrees", "wt-moved")
heads = self._refs(self.repo, "refs/heads")
for target in (
os.path.join(self.root, "reg"),
os.path.join(common, "worktrees", "nest", "wt-moved"),
):
with self.subTest(target=target):
os.renames(reg, target)
reg = target
with open(os.path.join(reg, "commondir"), "w", encoding="utf-8") as fh:
fh.write(common + "\n")
with open(os.path.join(moved, ".git"), "w", encoding="utf-8") as fh:
fh.write(f"gitdir: {reg}\n")
run = self._cleanup("local", apply=True, roots=(moved,))
code, out, err = self._cli_apply(moved)
self.assertEqual(run.code, 2)
self.assertEqual(run.items, {})
self.assertIn(f"git dir {reg} is neither", run.warnings[-1])
self.assertEqual(code, 2)
self.assertIn(f"git dir {reg} is neither", err)
self.assertEqual(out, "")
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
self.assertEqual(
git(moved, "symbolic-ref", "HEAD"), "refs/heads/local-done"
)
def test_symlink_below_refs_refuses_the_repository(self) -> None:
other = os.path.join(self.root, "other")
self._clone_with(other, "shared-done")
git(other, "checkout", "shared-done")
theirs = os.path.join(other, ".git", "refs", "heads")
own = os.path.join(self.repo, ".git")
before = self._refs(other, "refs/heads")
heads = self._refs(self.repo, "refs/heads")
git(self.repo, "pack-refs", "--all")
links = (
("refs/heads/team", theirs),
("logs/refs/heads/team", os.path.join(other, ".git", "logs", "refs")),
("refs/heads", theirs),
)
for link, target in links:
with self.subTest(link=link):
path = os.path.join(own, link)
if link == "refs/heads":
shutil.rmtree(path, ignore_errors=True)
os.makedirs(os.path.dirname(path), exist_ok=True)
os.symlink(target, path)
run = self._cleanup("local", apply=True)
os.remove(path)
self.assertEqual(run.code, 2)
self.assertTrue(run.warnings[-1].startswith(f"refused: {link} in "))
self.assertEqual(self._refs(other, "refs/heads"), before)
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_delete_merged_opt_out_and_local_upstreams_are_kept(self) -> None:
for name, start in (
("topic", "done"),
("inner", "done"),
("base", "done"),
("child", "wip"),
("stem", "done"),
("leaf", "done"),
):
git(self.repo, "branch", name, start)
for key, value in (
("branch.topic.deleteMerged", "false"),
("branch.child.remote", "."),
("branch.child.merge", "refs/heads/base"),
("branch.base.remote", "."),
("branch.base.merge", "refs/heads/inner"),
("branch.leaf.remote", "."),
("branch.leaf.merge", "refs/heads/stem"),
):
git(self.repo, "config", key, value)
run = self._cleanup("local", apply=True)
self.assertEqual(
self._results(run),
{"done": "done", "local-done": "done", "leaf": "done", "stem": "done"},
)
self.assertEqual(
{name: run.items[name]["skip"] for name in ("topic", "base", "inner")},
{
"topic": "kept: branch.topic.deleteMerged is false",
"base": "kept: local upstream of child",
"inner": "kept: local upstream of base",
},
)
self.assertEqual(self._branch_config("topic"), ["branch.topic.deletemerged"])
heads = self._refs(self.repo, "refs/heads")
for name in ("topic", "inner", "base", "child"):
self.assertIn(f"refs/heads/{name}", heads)
def test_filtered_out_branch_keeps_its_local_upstream(self) -> None:
git(self.repo, "branch", "x", "done")
git(self.repo, "branch", "y", "done")
git(self.repo, "config", "branch.x.remote", ".")
git(self.repo, "config", "branch.x.merge", "refs/heads/y")
for include, exclude in ((("y",), ()), ((), ("x",))):
with self.subTest(include=include, exclude=exclude):
run = self._cleanup(
"local", apply=True, include=include, exclude=exclude
)
self.assertEqual(run.items["y"]["skip"], "kept: local upstream of x")
self.assertIsNone(run.items["y"]["result"])
heads = self._refs(self.repo, "refs/heads")
self.assertIn("refs/heads/x", heads)
self.assertIn("refs/heads/y", heads)
def test_local_upstream_is_resolved_as_git_does(self) -> None:
forms = {"x1": "heads/y1", "x2": "y2", "x3": "refs/heads/y3"}
for child, merge in forms.items():
parent = f"y{child[1:]}"
git(self.repo, "branch", parent, "done")
git(self.repo, "branch", child, "wip")
git(self.repo, "config", f"branch.{child}.remote", ".")
git(self.repo, "config", f"branch.{child}.merge", merge)
self.assertEqual(
git(self.repo, "rev-parse", "--symbolic-full-name", f"{child}@{{u}}"),
f"refs/heads/{parent}",
)
run = self._cleanup("local", apply=True)
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
self.assertEqual(
{name: run.items[name]["skip"] for name in ("y1", "y2", "y3")},
{
"y1": "kept: local upstream of x1",
"y2": "kept: local upstream of x2",
"y3": "kept: local upstream of x3",
},
)
def test_worktree_config_opt_out_keeps_the_branch_from_every_root(self) -> None:
for name in ("y", "q"):
git(self.repo, "branch", name, "done")
w = os.path.join(self.root, "w")
git(self.repo, "worktree", "add", "-b", "feat/w", w, "wip")
git(self.repo, "config", "extensions.worktreeConfig", "true")
git(w, "config", "--worktree", "branch.y.deleteMerged", "false")
include = os.path.join(self.root, "keep.inc")
with open(include, "w", encoding="utf-8") as fh:
fh.write('[branch "q"]\n\tdeleteMerged = false\n')
git(self.repo, "config", "includeIf.onbranch:feat/**.path", include)
kept = {
"y": "kept: branch.y.deleteMerged is false",
"q": "kept: branch.q.deleteMerged is false",
}
for root in (self.repo, w):
with self.subTest(root=root):
run = self._cleanup("local", apply=False, roots=(root,))
self.assertEqual({n: run.items[n]["skip"] for n in kept}, kept)
code, out, _ = self._cli_apply(w)
items = {i["branch"]: i for i in json.loads(out)["repos"][0]["items"]}
self.assertEqual(code, 0)
self.assertEqual({n: items[n]["skip"] for n in kept}, kept)
self.assertEqual(
{n: i["result"] for n, i in items.items() if i["result"]},
{"done": "done", "local-done": "done"},
)
def test_system_config_opt_out_keeps_the_branch(self) -> None:
system = os.path.join(self.root, "system.cfg")
with open(system, "w", encoding="utf-8") as fh:
fh.write('[branch "local-done"]\n\tdeleteMerged = false\n')
run = self._cleanup("local", apply=True, leak={"GIT_CONFIG_SYSTEM": system})
self.assertEqual(self._results(run), {"done": "done"})
self.assertEqual(
run.items["local-done"]["skip"],
"kept: branch.local-done.deleteMerged is false",
)
def test_config_variables_reach_only_the_keep_reads(self) -> None:
global_config = os.path.join(self.root, "global.cfg")
with open(global_config, "w", encoding="utf-8") as fh:
fh.write('[branch "local-done"]\n\tdeleteMerged = false\n')
git(self.repo, "branch", "kid", "wip")
shapes = (
({"GIT_CONFIG_GLOBAL": global_config}, "local-done", "deleteMerged"),
(
{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "branch.local-done.deleteMerged",
"GIT_CONFIG_VALUE_0": "false",
},
"local-done",
"deleteMerged",
),
(
{
"GIT_CONFIG_COUNT": "2",
"GIT_CONFIG_KEY_0": "branch.kid.remote",
"GIT_CONFIG_VALUE_0": ".",
"GIT_CONFIG_KEY_1": "branch.kid.merge",
"GIT_CONFIG_VALUE_1": "refs/heads/done",
},
"done",
"upstream",
),
)
reasons = {
"deleteMerged": "kept: branch.local-done.deleteMerged is false",
"upstream": "kept: local upstream of kid",
}
for leak, branch, rule in shapes:
with self.subTest(names=sorted(leak), rule=rule):
run = self._cleanup("local", apply=False, leak=leak)
self.assertEqual(run.items[branch]["skip"], reasons[rule])
self.assertEqual(run.items[branch]["actions"], [])
views = {
(call.args[0][1], call.kwargs["config_view"])
for call in run.guard.git.call_args_list
if call.kwargs.get("config_view")
}
self.assertEqual(
views,
{
(command, view)
for command in ("config", "for-each-ref")
for view in (CALLER_CONFIG, DEFAULT_CONFIG)
},
)
def _kid_tracks_par(self) -> None:
git(self.repo, "branch", "par", "done")
git(self.repo, "branch", "kid", "wip")
def test_system_config_upstream_keeps_its_parent(self) -> None:
self._kid_tracks_par()
system = os.path.join(self.root, "system.cfg")
include = os.path.join(self.root, "inc.cfg")
kid_up = '[branch "kid"]\n\tremote = .\n\tmerge = refs/heads/par\n'
with open(include, "w", encoding="utf-8") as fh:
fh.write(kid_up)
shapes = {
"s1_whole_upstream": kid_up,
"s2_merge_line_only": '[branch "kid"]\n\tmerge = refs/heads/par\n',
"s3_include": f"[include]\n\tpath = {include}\n",
"s5_includeif_onbranch": f'[includeIf "onbranch:main"]\n\tpath = {include}\n',
}
for shape, text in shapes.items():
with self.subTest(shape=shape):
with open(system, "w", encoding="utf-8") as fh:
fh.write(text)
if shape.startswith("s2"):
git(self.repo, "config", "branch.kid.remote", ".")
run = self._cleanup(
"local", apply=False, leak={"GIT_CONFIG_SYSTEM": system}
)
self.assertEqual(
run.items["par"]["skip"], "kept: local upstream of kid"
)
self.assertEqual(run.items["par"]["actions"], [])
def test_injected_config_never_removes_a_keep(self) -> None:
self._kid_tracks_par()
git(self.repo, "config", "branch.kid.remote", ".")
git(self.repo, "config", "branch.kid.merge", "refs/heads/par")
git(self.repo, "config", "branch.local-done.deleteMerged", "false")
include = os.path.join(self.root, "inc.cfg")
with open(include, "w", encoding="utf-8") as fh:
fh.write('[branch "kid"]\n\tremote = /nonexistent/url\n')
count = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "branch.kid.remote"}
leaks = {
"e1_count_remote": {**count, "GIT_CONFIG_VALUE_0": "origin"},
"e2_params_remote": {
"GIT_CONFIG_PARAMETERS": "'branch.kid.remote'='origin'"
},
"e3_params_include": {
"GIT_CONFIG_PARAMETERS": f"'include.path'='{include}'"
},
"e4_count_url": {
**count,
"GIT_CONFIG_VALUE_0": "https://example.invalid/x.git",
},
"e5_params_delete_merged": {
"GIT_CONFIG_PARAMETERS": "'branch.local-done.deletemerged'='true'"
},
}
for shape, leak in leaks.items():
with self.subTest(shape=shape):
run = self._cleanup("local", apply=False, leak=leak)
self.assertEqual(
run.items["par"]["skip"], "kept: local upstream of kid"
)
self.assertEqual(
run.items["local-done"]["skip"],
"kept: branch.local-done.deleteMerged is false",
)
run = self._cleanup("local", apply=True, leak=leaks["e1_count_remote"])
self.assertEqual(self._results(run), {"done": "done"})
heads = self._refs(self.repo, "refs/heads")
self.assertIn("refs/heads/par", heads)
self.assertIn("refs/heads/local-done", heads)
def test_hostile_home_never_hides_the_passwd_home_keep(self) -> None:
self._kid_tracks_par()
home = os.path.join(self.root, "passwd-home")
os.makedirs(home)
with open(os.path.join(home, ".gitconfig"), "w", encoding="utf-8") as fh:
fh.write(
'[branch "kid"]\n\tremote = .\n\tmerge = refs/heads/par\n'
'[branch "local-done"]\n\tdeleteMerged = false\n'
)
hostile = os.path.join(self.root, "hostile-home")
os.makedirs(hostile)
with patch("pkgmgr.core.git.run._passwd_home", return_value=home):
run = self._cleanup(
"local",
apply=True,
leak={
"HOME": hostile,
"XDG_CONFIG_HOME": hostile,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_SYSTEM": os.devnull,
},
)
self.assertEqual(self._results(run), {"done": "done"})
self.assertEqual(run.items["par"]["skip"], "kept: local upstream of kid")
self.assertEqual(
run.items["local-done"]["skip"],
"kept: branch.local-done.deleteMerged is false",
)
heads = self._refs(self.repo, "refs/heads")
self.assertIn("refs/heads/par", heads)
self.assertIn("refs/heads/local-done", heads)
def test_local_upstream_from_a_worktree_config_keeps_its_parent(self) -> None:
for name in ("par1", "par2", "par3"):
git(self.repo, "branch", name, "done")
for name in ("kid2", "kid3"):
git(self.repo, "branch", name, "wip")
w1 = os.path.join(self.root, "w1")
w2 = os.path.join(self.root, "w2")
git(self.repo, "worktree", "add", "-b", "kid1", w1, "wip")
git(self.repo, "worktree", "add", "-b", "feat/w2", w2, "wip")
git(self.repo, "config", "extensions.worktreeConfig", "true")
for checkout, child, parent in ((w1, "kid1", "par1"), (w2, "kid2", "par2")):
git(checkout, "config", "--worktree", f"branch.{child}.remote", ".")
git(
checkout,
"config",
"--worktree",
f"branch.{child}.merge",
f"refs/heads/{parent}",
)
include = os.path.join(self.root, "up.inc")
with open(include, "w", encoding="utf-8") as fh:
fh.write('[branch "kid3"]\n\tremote = .\n\tmerge = refs/heads/par3\n')
git(self.repo, "config", "includeIf.onbranch:feat/**.path", include)
kept = {f"par{n}": f"kept: local upstream of kid{n}" for n in (1, 2, 3)}
run = self._cleanup("local", apply=False)
code, out, _ = self._cli_apply(w2)
self.assertEqual({n: run.items[n]["skip"] for n in kept}, kept)
items = {i["branch"]: i for i in json.loads(out)["repos"][0]["items"]}
self.assertEqual(code, 0)
self.assertEqual({n: items[n]["skip"] for n in kept}, kept)
self.assertEqual(
{n: i["result"] for n, i in items.items() if i["result"]},
{"done": "done", "local-done": "done"},
)
heads = self._refs(self.repo, "refs/heads")
for name in kept:
self.assertIn(f"refs/heads/{name}", heads)
def test_inherited_git_variables_never_move_the_local_root(self) -> None:
sentinel = os.path.join(self.root, "sentinel")
git(self.root, "init", "-b", "trunk", sentinel)
dot_git = os.path.join(sentinel, ".git")
with patch.dict(os.environ, {"GIT_DIR": dot_git, "GIT_COMMON_DIR": dot_git}):
self.assertEqual(gc.local_repo_root(self.held), self.repo)
def test_failed_head_read_refuses_the_repository(self) -> None:
heads = self._refs(self.repo, "refs/heads")
real = gc._git
def timed_out(args: list[str], cwd: str) -> str:
if args[1:] == ["symbolic-ref", "--quiet", "HEAD"]:
err = GitRunError("Git command timed out")
err.returncode = -1
raise err
return real(args, cwd)
with patch.object(gc, "_git", side_effect=timed_out):
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 2)
self.assertEqual(run.items, {})
self.assertEqual(run.warnings[-1], "refused: Git command timed out")
self.assertEqual(self._refs(self.repo, "refs/heads"), heads)
def test_locked_config_is_a_note_on_a_done_item(self) -> None:
with open(os.path.join(self.repo, ".git", "config.lock"), "w") as fh:
fh.write("")
run = self._cleanup("local", apply=True)
self.assertEqual(run.code, 0)
self.assertTrue(
run.items["done"]["result"].startswith("done; config branch.done kept: ")
)
self.assertTrue(run.items["local-done"]["result"].startswith("done"))
self.assertNotIn("refs/heads/done", self._refs(self.repo, "refs/heads"))
self.assertEqual(
self._branch_config("done"), ["branch.done.remote", "branch.done.merge"]
)
def test_repository_hooks_never_run(self) -> None:
marker = os.path.join(self.root, "hook.log")
hooks = os.path.join(self.repo, ".git", "hooks")
os.makedirs(hooks, exist_ok=True)
for name in ("reference-transaction", "post-checkout"):
path = os.path.join(hooks, name)
with open(path, "w", encoding="utf-8") as fh:
fh.write(MARKING_HOOK.format(marker=marker))
os.chmod(path, 0o755)
run = self._cleanup("local", apply=True)
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)
self.assertEqual(run.code, 0)
self.assertIn("forge unavailable: no GitHub token (none)", run.warnings)
self.assertEqual(run.items["done"]["skip"], "forge unavailable")
self.assertEqual(run.items["remote-done"]["skip"], "forge unavailable")
self.assertIsNone(run.items["local-done"]["skip"])
self.assertEqual(run.items["local-done"]["actions"], ["local-branch"])
def test_cli_local_apply_reads_no_token_and_keeps_the_tree_file(self) -> None:
tree_path = os.path.join(self.root, "branch-tree.json")
with open(tree_path, "w", encoding="utf-8") as fh:
json.dump(
{"repos": [{"path": self.repo, "known": True, "scanned_at": ""}]}, fh
)
with open(tree_path, "rb") as fh:
before = fh.read()
args = create_parser("test").parse_args(
["branch", "cleanup", "--local", "--apply", "--json"]
)
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
out = io.StringIO()
with (
self._guarded() as guard,
patch.dict(os.environ, {"PKGMGR_TREE_FILE": tree_path}),
patch("pkgmgr.cli.commands.branch.local_repo_root", return_value=self.repo),
redirect_stdout(out),
redirect_stderr(io.StringIO()),
self.assertRaises(SystemExit) as exit_ctx,
):
handle_branch(args, ctx=ctx)
self.assertEqual(exit_ctx.exception.code, 0)
items = {
i["branch"]: i for i in json.loads(out.getvalue())["repos"][0]["items"]
}
self.assertEqual(items["done"]["result"], "done")
self.assertNotIn("refs/heads/done", self._refs(self.repo, "refs/heads"))
with open(tree_path, "rb") as fh:
self.assertEqual(fh.read(), before)
self.assertLocalOnly(guard)
if __name__ == "__main__":
unittest.main()