Files
pkgmgr/tests/integration/test_worktree.py
T

761 lines
31 KiB
Python
Raw Normal View History

from __future__ import annotations
import io
import json
import os
import shlex
import shutil
import sys
import time
import unittest
from contextlib import redirect_stderr, redirect_stdout
from types import SimpleNamespace
from unittest.mock import patch
import yaml
from pkgmgr.actions.branch import gc
from pkgmgr.actions.worktree import hooks, orphans, up
from pkgmgr.cli.commands.worktree import handle_gc, handle_worktree
from pkgmgr.cli.context import CLIContext
from pkgmgr.cli.dispatch import dispatch_command
from pkgmgr.cli.parser import create_parser
from pkgmgr.core.config.load import load_config
from tests.integration.test_branch_gc import GcFixture, git
class WorktreeFixture(GcFixture):
def _pushed_worktree(self, name: str, makefile: str | None) -> str:
git(self.repo, "checkout", "-b", name, "main")
if makefile is None:
self._commit(name)
else:
with open(os.path.join(self.repo, "Makefile"), "w") as fh:
fh.write(makefile)
git(self.repo, "add", "Makefile")
git(self.repo, "commit", "-m", f"{name} makefile")
git(self.repo, "push", "origin", name)
git(self.repo, "checkout", "main")
path = os.path.join(self.root, f"wt-{name}")
git(self.repo, "worktree", "add", path, name)
return path
def _execute(self, plan: gc.Repo, item: gc.Item) -> None:
with patch.object(gc, "_process_cwds", return_value={}):
gc.execute(plan, item)
def _pinned(self, hook: str, argv: list[str]) -> dict:
return {os.path.realpath(self.repo): {hook: argv}}
class TestCleanupScopes(WorktreeFixture, unittest.TestCase):
def test_worktree_scope_removes_pushed_worktree_and_keeps_branch(self) -> None:
path = self._pushed_worktree("pushed", None)
plan = self._plan(host_ns=True, cwds={}, scope="worktree")
items = self._items(plan)
self.assertNotIn("squashed", items)
item = items["pushed"]
self.assertEqual(item.state, "unmerged")
self.assertEqual(item.unpushed, 0)
self.assertEqual(item.actions, ["worktree"])
self.assertEqual(gc._label(item), "REMOVE")
self._execute(plan, item)
self.assertEqual(item.result, "done")
self.assertFalse(os.path.exists(path))
self.assertNotEqual(git(self.repo, "branch", "--list", "pushed"), "")
self.assertNotEqual(git(self.origin, "branch", "--list", "pushed"), "")
def test_worktree_scope_keeps_unpushed_worktree(self) -> None:
path = self._pushed_worktree("ahead", None)
with open(os.path.join(path, "more.txt"), "w") as fh:
fh.write("more")
git(path, "add", "more.txt")
git(path, "commit", "-m", "more")
item = self._items(self._plan(host_ns=True, cwds={}, scope="worktree"))["ahead"]
self.assertEqual(item.unpushed, 1)
self.assertEqual(item.actions, [])
self.assertFalse(item.eligible)
def test_branch_scope_skips_checked_out_and_takes_free_branches(self) -> None:
items = self._items(self._plan(host_ns=True, cwds={}, scope="branch"))
self.assertTrue(items["merged"].skip.startswith("checked out in worktree"))
self.assertEqual(items["merged"].actions, [])
self.assertEqual(gc._label(items["merged"]), "SKIP")
self.assertEqual(
items["squashed"].actions, ["local-branch", "remote-branch:origin"]
)
self.assertTrue(items["squashed"].eligible)
def test_patch_equivalent_branch_counts_as_merged(self) -> None:
git(self.repo, "checkout", "-b", "picked", "main")
self._commit("picked")
git(self.repo, "push", "origin", "picked")
sha = git(self.repo, "rev-parse", "HEAD")
git(self.repo, "checkout", "main")
self._commit("main-moved")
git(self.repo, "cherry-pick", sha)
git(self.repo, "push", "origin", "main")
item = self._items(self._plan(host_ns=True, cwds={}))["picked"]
self.assertEqual(item.merged_by, "patch")
self.assertEqual(item.state, "merged-clean")
def test_dotdot_behind_a_symlink_never_reaches_another_checkout(self) -> None:
victim = os.path.join(self.root, "victim")
os.makedirs(victim)
git(victim, "init", "--quiet", "-b", "main")
git(victim, "commit", "--quiet", "--allow-empty", "-m", "unpushed work")
git(self.repo, "branch", "evil", "main")
hop = os.path.join(self.repo, "A")
os.makedirs(os.path.join(hop, "victim"))
os.symlink(victim, os.path.join(hop, "L"))
meta = os.path.join(self.repo, ".git", "worktrees", "evil")
os.makedirs(meta)
files = {
os.path.join(hop, "victim", ".git"): f"gitdir: {meta}\n",
os.path.join(meta, "gitdir"): os.path.join(
hop, "L", "..", "victim", ".git"
),
os.path.join(meta, "HEAD"): "ref: refs/heads/evil\n",
os.path.join(meta, "commondir"): "../..\n",
}
for name, text in files.items():
with open(name, "w") as fh:
fh.write(text + ("" if text.endswith("\n") else "\n"))
plan = self._plan(host_ns=True, cwds={}, scope="worktree")
item = self._items(plan)["evil"]
self._execute(plan, item)
self.assertNotIn("worktree", item.actions)
self.assertTrue(os.path.isdir(os.path.join(victim, ".git")))
def test_pinned_fini_hook_runs_before_removal(self) -> None:
marker = os.path.join(self.root, "fini-ran")
path = self._pushed_worktree("hooked", None)
pins = self._fini_pins(["touch", marker])
plan = self._plan(host_ns=True, cwds={}, scope="worktree", hooks=pins)
item = self._items(plan)["hooked"]
self.assertEqual(item.actions, ["hook:worktree-fini", "worktree"])
self._execute(plan, item)
self.assertEqual(item.result, "done")
self.assertTrue(os.path.exists(marker))
self.assertFalse(os.path.exists(path))
def _fini_pins(self, argv: list[str]) -> dict:
return self._pinned("worktree-fini", argv)
def _cleanup_merged_forged_to(self, target: str) -> dict:
meta = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
with open(os.path.join(meta, "gitdir"), "w") as fh:
fh.write(os.path.join(target, ".git") + "\n")
pins = self._fini_pins(["touch", "{path}/fini-ran"])
out = io.StringIO()
with (
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value={}),
redirect_stdout(out),
redirect_stderr(io.StringIO()),
):
gc.cleanup(
[self.repo],
scope="worktree",
apply=True,
as_json=True,
include=["merged"],
exclude=[],
merged_into=[],
sizes=False,
min_age=None,
hooks=pins,
)
return json.loads(out.getvalue())["repos"][0]["items"][0]
def test_forged_gitdir_runs_nothing_in_another_checkout(self) -> None:
other = os.path.join(self.root, "other")
git(self.root, "clone", "--quiet", self.repo, other)
item = self._cleanup_merged_forged_to(other)
self.assertFalse(os.path.exists(os.path.join(other, "fini-ran")))
self.assertEqual(item["worktree"], other)
self.assertEqual(item["skip"], gc.NOT_LINKED)
self.assertEqual(item["actions"], [])
self.assertTrue(os.path.isdir(os.path.join(other, ".git")))
def test_gitdir_forged_to_a_sibling_worktree_runs_nothing_there(self) -> None:
sibling = self._pushed_worktree("sibling", None)
item = self._cleanup_merged_forged_to(sibling)
self.assertFalse(os.path.exists(os.path.join(sibling, "fini-ran")))
self.assertEqual(item["worktree"], sibling)
self.assertEqual(item["skip"], gc.NOT_LINKED)
self.assertEqual(item["actions"], [])
self.assertTrue(os.path.isdir(sibling))
def test_fini_hook_commit_keeps_worktree_and_branch(self) -> None:
commit = [
"git",
"-c",
"user.name=t",
"-c",
"user.email=t@example.org",
"-c",
"commit.gpgsign=false",
"commit",
"--quiet",
"--allow-empty",
"-m",
"hook",
]
plan = self._plan(host_ns=True, cwds={}, hooks=self._fini_pins(commit))
item = self._items(plan)["merged"]
self.assertIn("local-branch", item.actions)
self._execute(plan, item)
self.assertEqual(
item.result, "skipped: changed during worktree-fini (HEAD moved)"
)
self.assertTrue(os.path.isdir(self.merged_wt))
self.assertNotEqual(git(self.repo, "rev-parse", "merged"), item.local_sha)
def test_fini_hook_timeout_kills_its_process_group(self) -> None:
self._pushed_worktree("spawner", None)
marker = os.path.join(self.root, "grandchild-ran")
script = f"(sleep 1; touch {shlex.quote(marker)}) & sleep 30"
pins = self._fini_pins(["sh", "-c", script])
plan = self._plan(host_ns=True, cwds={}, scope="worktree", hooks=pins)
item = self._items(plan)["spawner"]
with patch.object(hooks, "HOOK_TIMEOUT", 0.3):
self._execute(plan, item)
time.sleep(1.5)
self.assertEqual(item.result, "failed: worktree-fini timed out after 0.3 s")
self.assertFalse(os.path.exists(marker))
def test_fini_hook_timeout_fails_without_removal(self) -> None:
path = self._pushed_worktree("slow", None)
sleep = [sys.executable, "-c", "import time; time.sleep(30)"]
pins = self._fini_pins(sleep)
plan = self._plan(host_ns=True, cwds={}, scope="worktree", hooks=pins)
item = self._items(plan)["slow"]
with patch.object(hooks, "HOOK_TIMEOUT", 0.5):
self._execute(plan, item)
self.assertEqual(item.result, "failed: worktree-fini timed out after 0.5 s")
self.assertTrue(os.path.isdir(path))
def test_fini_hook_output_keeps_the_worktree(self) -> None:
path = self._pushed_worktree("dumps", None)
pins = self._fini_pins(["touch", "{path}/dump.sql"])
plan = self._plan(host_ns=True, cwds={}, scope="worktree", hooks=pins)
item = self._items(plan)["dumps"]
self._execute(plan, item)
self.assertEqual(
item.result, "skipped: changed during worktree-fini (dirty: 1 paths)"
)
self.assertTrue(os.path.isfile(os.path.join(path, "dump.sql")))
@unittest.skipUnless(shutil.which("make"), "make is not available")
def test_makefile_hooks_never_run_unpinned(self) -> None:
marker = os.path.join(self.root, "fini-ran")
planted = f"worktree-fini:\n\ttouch {marker}\n"
path = self._pushed_worktree("planted", makefile=planted)
with open(os.path.join(self.repo, ".git", "info", "exclude"), "a") as fh:
fh.write("GNUmakefile\n")
with open(os.path.join(path, "GNUmakefile"), "w") as fh:
fh.write(planted)
plan = self._plan(host_ns=True, cwds={}, scope="worktree")
item = self._items(plan)["planted"]
self.assertEqual(item.actions, ["worktree"])
self._execute(plan, item)
self.assertEqual(item.result, "done")
self.assertFalse(os.path.exists(marker))
def test_activity_is_recorded_on_request(self) -> None:
quiet = self._items(self._plan(host_ns=True, cwds={}))
loud = self._items(self._plan(host_ns=True, cwds={}, activity=True))
self.assertIsNone(quiet["merged"].last_activity)
self.assertLessEqual(loud["merged"].last_activity, time.time())
class TestWorktreeUp(WorktreeFixture, unittest.TestCase):
def setUp(self) -> None:
super().setUp()
self.worktrees = os.path.join(self.root, "wts")
def _up(
self, branch: str, path: str | None, hooks: dict | None = None
) -> tuple[int, dict]:
out = io.StringIO()
with redirect_stdout(out):
code = up.worktree_up(
self.repo,
branch,
base=None,
worktrees_dir=self.worktrees,
path=path,
hooks=hooks or {},
as_json=True,
)
return code, json.loads(out.getvalue()) if code != 2 else {}
def _commit_file(self, name: str, text: str) -> None:
with open(os.path.join(self.repo, name), "w") as fh:
fh.write(text)
git(self.repo, "add", name)
git(self.repo, "commit", "-m", name)
git(self.repo, "push", "origin", "main")
def _commit_script(self, name: str) -> str:
marker = os.path.join(self.root, f"{name}-ran")
script = os.path.join(self.repo, name)
with open(script, "w") as fh:
fh.write(f"#!/bin/sh\ntouch {marker}\n")
os.chmod(script, 0o755)
git(self.repo, "add", name)
git(self.repo, "commit", "-m", name)
git(self.repo, "push", "origin", "main")
return marker
def _pins(self, argv: list[str]) -> dict:
return self._pinned("worktree-init", argv)
def test_slot_in_branch_name_stays_literal(self) -> None:
write_path = "import sys; open('path.txt', 'w').write(sys.argv[1])"
pins = self._pins([sys.executable, "-c", write_path, "{path}"])
code, result = self._up("feature/x{slot}", None, pins)
self.assertEqual(code, 0)
self.assertTrue(result["path"].endswith("/feature/x{slot}"))
with open(os.path.join(result["path"], "path.txt")) as fh:
self.assertEqual(fh.read(), result["path"])
@unittest.skipUnless(shutil.which("make"), "make is not available")
def test_empty_path_entry_never_runs_a_planted_command(self) -> None:
marker = self._commit_script("make")
search = os.pathsep + os.environ["PATH"]
with patch.dict(os.environ, {"PATH": search}):
code, result = self._up("feature/p", None, self._pins(["make", "-v"]))
self.assertEqual(code, 0)
self.assertEqual(result["hook"], "worktree-init")
self.assertFalse(os.path.exists(marker))
def test_bare_pin_skips_a_path_entry_another_user_can_write(self) -> None:
planted = os.path.join(self.root, "planted-bin")
os.makedirs(planted)
marker = os.path.join(self.root, "planted-ran")
fake = os.path.join(planted, "true")
with open(fake, "w") as fh:
fh.write(f"#!/bin/sh\ntouch {marker}\n")
os.chmod(fake, 0o755)
if os.getuid() == 0:
os.chown(planted, 65534, 65534)
os.chown(fake, 65534, 65534)
search = planted + os.pathsep + os.environ["PATH"]
with patch.dict(os.environ, {"PATH": search}):
code, result = self._up("feature/t", None, self._pins(["true"]))
self.assertEqual((code, result["hook"]), (0, "worktree-init"))
self.assertFalse(os.path.exists(marker))
def test_relative_pin_is_refused_before_checkout(self) -> None:
marker = self._commit_script("init.sh")
code, _ = self._up("feature/r", None, self._pins(["./init.sh"]))
self.assertEqual(code, 2)
self.assertFalse(os.path.exists(marker))
self.assertFalse(os.path.exists(self.worktrees))
def test_init_hook_timeout_fails_and_keeps_worktree(self) -> None:
pins = self._pins([sys.executable, "-c", "import time; time.sleep(30)"])
with patch.object(hooks, "HOOK_TIMEOUT", 0.5):
code, result = self._up("feature/t", None, pins)
self.assertEqual(code, 1)
self.assertEqual(
result["hook"],
"worktree-init failed (worktree-init timed out after 0.5 s)",
)
self.assertTrue(os.path.isdir(result["path"]))
def test_location_of_remote_urls(self) -> None:
cases = {
"git@github.com:owner/repo.git": "github.com/owner/repo",
"https://github.com/owner/repo": "github.com/owner/repo",
"ssh://git@host:2201/owner/repo.git": "host/owner/repo",
"/tmp/x/origin.git": "tmp/x/origin",
}
for url, expected in cases.items():
with self.subTest(url=url):
self.assertEqual(up.worktree_location(url), expected)
def test_new_branches_get_path_slots_and_pinned_init_hook(self) -> None:
write_slot = "import sys; open('slot.txt', 'w').write(sys.argv[1])"
hooks = self._pins([sys.executable, "-c", write_slot, "{slot}"])
code, first = self._up("feature/x", None, hooks)
expected = os.path.join(
self.worktrees, up.worktree_location(self.origin), "feature/x"
)
self.assertEqual(code, 0)
self.assertEqual(first["path"], expected)
self.assertEqual(first["slot"], 1)
self.assertEqual(first["hook"], "worktree-init")
self.assertTrue(first["origin"].startswith("new from origin/main"))
with open(os.path.join(expected, "slot.txt")) as fh:
self.assertEqual(fh.read().strip(), "1")
_, second = self._up("feature/y", None, hooks)
self.assertEqual(second["slot"], 2)
self.assertEqual(self._up("feature/x", None, hooks)[0], 2)
@unittest.skipUnless(shutil.which("make"), "make is not available")
def test_unpinned_makefile_is_never_run_or_probed(self) -> None:
parsed = os.path.join(self.root, "parsed")
ran = os.path.join(self.root, "ran")
self._commit_file(
"Makefile", f"X := $(shell touch {parsed})\nworktree-init:\n\ttouch {ran}\n"
)
code, result = self._up("feature/m", None)
self.assertEqual(code, 0)
self.assertEqual(result["hook"], "not pinned: worktree-init in Makefile")
self.assertFalse(os.path.exists(parsed))
self.assertFalse(os.path.exists(ran))
def test_origin_url_cannot_escape_the_worktrees_dir(self) -> None:
git(self.repo, "remote", "set-url", "origin", "https://host/../../escape")
self.assertEqual(self._up("feature/e", None)[0], 2)
self.assertFalse(os.path.exists(os.path.join(self.root, "escape")))
def test_default_base_named_like_an_option_is_a_ref(self) -> None:
git(self.repo, "checkout", "-b", "side", "main")
self._commit("side")
sha = git(self.repo, "rev-parse", "HEAD")
git(self.repo, "checkout", "main")
git(self.repo, "update-ref", "refs/heads/-f", sha)
git(self.repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/-f")
code, result = self._up("feature/o", None)
self.assertEqual(code, 0)
self.assertEqual(git(result["path"], "rev-parse", "HEAD"), sha)
def test_local_filter_config_blocks_the_checkout(self) -> None:
marker = os.path.join(self.root, "smudged")
self._commit_file(".gitattributes", "* filter=evil\n")
git(self.repo, "config", "filter.evil.smudge", f"touch {marker}; cat")
self.assertEqual(self._up("feature/s", None)[0], 2)
self.assertFalse(os.path.exists(marker))
def test_local_ssh_command_skips_the_fetch(self) -> None:
marker = os.path.join(self.root, "ssh-ran")
git(self.repo, "config", "core.sshCommand", f"touch {marker}; false")
git(self.repo, "config", "remote.origin.pushurl", self.origin)
git(self.repo, "remote", "set-url", "origin", "ssh://example.invalid/r.git")
code, result = self._up("feature/f", None)
self.assertEqual(code, 0)
self.assertFalse(os.path.exists(marker))
self.assertIn("core.sshcommand", " ".join(result["warnings"]))
def test_remote_branch_is_tracked(self) -> None:
git(self.repo, "checkout", "-b", "remote-only", "main")
self._commit("remote-only")
git(self.repo, "push", "origin", "remote-only")
git(self.repo, "checkout", "main")
git(self.repo, "branch", "-D", "remote-only")
code, result = self._up("remote-only", None)
self.assertEqual(code, 0)
self.assertEqual(result["origin"], "tracking origin/remote-only")
self.assertEqual(result["hook"], "none")
self.assertEqual(
git(result["path"], "rev-parse", "--abbrev-ref", "@{u}"),
"origin/remote-only",
)
def test_explicit_path_replaces_standard_path(self) -> None:
released = os.path.join(self.root, "released", "feature-z")
code, result = self._up("feature/z", released)
self.assertEqual(code, 0)
self.assertEqual(result["path"], released)
self.assertEqual(
git(released, "rev-parse", "--abbrev-ref", "HEAD"), "feature/z"
)
self.assertFalse(os.path.exists(self.worktrees))
def test_invalid_branch_is_refused(self) -> None:
self.assertEqual(self._up("../evil", None)[0], 2)
self.assertFalse(os.path.exists(self.worktrees))
class TestWorktreeOrphans(WorktreeFixture, unittest.TestCase):
def _report(self, cwds: dict[int, str]) -> dict[str, dict]:
out = io.StringIO()
with (
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value=cwds),
redirect_stdout(out),
):
code = orphans.worktree_orphans(
[self.repo],
stale_days=0,
top=5,
as_json=True,
include=[],
exclude=[],
merged_into=[],
sizes=False,
)
self.assertEqual(code, 0)
rows = json.loads(out.getvalue())["repos"][0]["rows"]
return {row["branch"]: row for row in rows if row["branch"]}
def test_classes_and_proposals(self) -> None:
git(self.repo, "checkout", "-b", "lonely", "main")
self._commit("lonely")
git(self.repo, "checkout", "main")
time.sleep(1.1)
rows = self._report({})
self.assertEqual(rows["main"]["class"], "active")
self.assertEqual(rows["merged"]["class"], "merged-clean")
self.assertEqual(rows["merged"]["proposal"], "pkgmgr gc")
self.assertEqual(rows["dirty"]["class"], "merged-dirty")
self.assertEqual(rows["lonely"]["class"], "stale-open")
self.assertEqual(rows["lonely"]["unpushed"], 1)
self.assertEqual(rows["wip"]["class"], "active")
def test_busy_worktree_is_kept(self) -> None:
rows = self._report({4242: self.merged_wt})
self.assertEqual(rows["merged"]["class"], "merged-clean")
self.assertTrue(
rows["merged"]["proposal"].startswith("keep (process: pid 4242")
)
class TestLoadedConfigCli(WorktreeFixture, unittest.TestCase):
HOOKS = ("worktree-init", "worktree-fini")
def setUp(self) -> None:
super().setUp()
self.addCleanup(os.chdir, os.getcwd())
self.markers = {h: os.path.join(self.root, f"{h}-ran") for h in self.HOOKS}
def _ctx(self, directory: str, pinned: bool) -> CLIContext:
location = up.worktree_location(self.origin)
provider, _, rest = location.partition("/")
account, _, repository = rest.rpartition("/")
config: dict = {
"directories": {"worktrees": os.path.join(self.root, "wts")},
"repositories": [
{
"provider": provider,
"account": account,
"repository": repository,
"directory": directory,
}
],
}
if pinned:
config["worktree_hooks"] = {
location: {h: ["touch", m] for h, m in self.markers.items()}
}
config_dir = os.path.join(self.root, "config")
os.makedirs(config_dir, exist_ok=True)
user_config = os.path.join(config_dir, "config.yaml")
with open(user_config, "w") as fh:
yaml.safe_dump(config, fh)
merged = load_config(user_config)
return CLIContext(
config_merged=merged,
repositories_base_dir=os.path.join(self.root, "repos"),
all_repositories=merged["repositories"],
binaries_dir=os.path.join(self.root, "bin"),
user_config_path=user_config,
)
def _run(self, argv: list[str], ctx: CLIContext) -> tuple[int, str]:
os.chdir(self.repo)
out = io.StringIO()
with (
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value={}),
patch.object(gc, "_docker", return_value=""),
patch("pkgmgr.cli.commands.branch.refresh_tree"),
patch("pkgmgr.cli.commands.worktree.refresh_tree"),
redirect_stdout(out),
self.assertRaises(SystemExit) as exit_ctx,
):
dispatch_command(create_parser("test").parse_args(argv), ctx)
return exit_ctx.exception.code, out.getvalue()
def test_gc_dry_run_with_config_lacking_worktree_hooks(self) -> None:
code, out = self._run(["gc", "--json"], self._ctx(self.repo, pinned=False))
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["repos"][0]["repo"], self.repo)
def test_origin_url_of_a_managed_repository_selects_no_pin(self) -> None:
managed = os.path.join(self.root, "managed")
git(self.root, "clone", "--quiet", self.repo, managed)
ctx = self._ctx(managed, pinned=True)
code, out = self._run(["worktree", "up", "feature/a", "--json"], ctx)
self.assertEqual((code, json.loads(out)["hook"]), (0, "none"))
self.assertEqual(self._run(["gc", "--apply", "--branch", "merged"], ctx)[0], 0)
self.assertFalse(os.path.exists(self.markers["worktree-init"]))
self.assertFalse(os.path.exists(self.markers["worktree-fini"]))
self.assertFalse(os.path.exists(self.merged_wt))
def test_pinned_directory_borrowing_another_git_dir_runs_no_pin(self) -> None:
borrower = os.path.join(self.root, "borrower")
git(self.root, "init", "--quiet", "-b", "main", borrower)
with open(os.path.join(borrower, ".git", "commondir"), "w") as fh:
fh.write(os.path.join(self.repo, ".git") + "\n")
ctx = self._ctx(borrower, pinned=True)
code, out = self._run(
["gc", "--all", "--apply", "--json", "--branch", "merged"], ctx
)
warnings = json.loads(out)["repos"][0]["warnings"]
self.assertEqual(code, 0)
self.assertTrue(any("pinned hooks skipped" in w for w in warnings))
self.assertFalse(os.path.exists(self.markers["worktree-fini"]))
def test_managed_repository_runs_its_pins(self) -> None:
ctx = self._ctx(self.repo, pinned=True)
code, out = self._run(["worktree", "up", "feature/b", "--json"], ctx)
self.assertEqual((code, json.loads(out)["hook"]), (0, "worktree-init"))
self.assertEqual(self._run(["gc", "--apply", "--branch", "merged"], ctx)[0], 0)
self.assertTrue(os.path.exists(self.markers["worktree-init"]))
self.assertTrue(os.path.exists(self.markers["worktree-fini"]))
self.assertFalse(os.path.exists(self.merged_wt))
class TestWorktreeCli(unittest.TestCase):
def test_branch_gc_is_gone(self) -> None:
with self.assertRaises(SystemExit):
create_parser("test").parse_args(["branch", "gc"])
def test_up_forwards_to_worktree_up(self) -> None:
args = create_parser("test").parse_args(
[
"worktree",
"up",
"feature/x",
"--base",
"origin/main",
"--path",
"/srv/wt/x",
"--json",
]
)
pins = {"worktree-init": ["true"]}
ctx = SimpleNamespace(
repositories_base_dir="/srv",
config_merged={
"directories": {"worktrees": "~/wt"},
"repositories": [
{"provider": "host", "account": "o", "repository": "r"}
],
"worktree_hooks": {"host/o/r": pins, "host/o/unmanaged": pins},
},
)
with (
patch("pkgmgr.cli.commands.worktree.worktree_up", return_value=0) as run_up,
patch("pkgmgr.cli.commands.worktree.refresh_tree") as refresh,
self.assertRaises(SystemExit) as exit_ctx,
):
handle_worktree(args, ctx)
self.assertEqual(exit_ctx.exception.code, 0)
run_up.assert_called_once_with(
".",
"feature/x",
base="origin/main",
worktrees_dir="~/wt",
path="/srv/wt/x",
hooks={os.path.realpath("/srv/host/o/r"): pins},
as_json=True,
)
refresh.assert_called_once()
def test_cleanup_and_gc_pick_their_scope(self) -> None:
for argv, handler, scope in (
(["worktree", "cleanup", "--apply"], handle_worktree, "worktree"),
(["gc", "--apply"], handle_gc, "all"),
):
with self.subTest(scope=scope):
args = create_parser("test").parse_args(argv)
ctx = SimpleNamespace(
config_merged={"worktree_hooks": {}, "repositories": []}
)
with (
patch(
"pkgmgr.cli.commands.branch.main_repo_root", return_value="/r"
),
patch(
"pkgmgr.cli.commands.branch.cleanup", return_value=0
) as run_cleanup,
patch("pkgmgr.cli.commands.branch.refresh_tree") as refresh,
self.assertRaises(SystemExit),
):
handler(args, ctx)
self.assertEqual(run_cleanup.call_args.kwargs["scope"], scope)
self.assertTrue(run_cleanup.call_args.kwargs["apply"])
self.assertEqual(run_cleanup.call_args.kwargs["hooks"], {})
refresh.assert_called_once_with(["/r"])
def test_orphans_requires_explicit_limits(self) -> None:
with self.assertRaises(SystemExit):
create_parser("test").parse_args(["worktree", "orphans"])
args = create_parser("test").parse_args(
["worktree", "orphans", "--stale-days", "7", "--top", "5"]
)
self.assertEqual((args.stale_days, args.top), (7, 5))
if __name__ == "__main__":
unittest.main()