2026-11-07 11:18:57 +00:00
|
|
|
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))
|
|
|
|
|
|
2026-11-07 13:26:40 +00:00
|
|
|
def test_checkout_runs_no_config_hook(self) -> None:
|
|
|
|
|
marker = os.path.join(self.root, "hooked")
|
|
|
|
|
git(self.repo, "config", "hook.probe.command", f"touch {marker}")
|
|
|
|
|
git(self.repo, "config", "hook.probe.event", "post-checkout")
|
|
|
|
|
|
|
|
|
|
code, _ = self._up("feature/h", None)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(code, 0)
|
|
|
|
|
self.assertFalse(os.path.exists(marker))
|
|
|
|
|
|
2026-11-07 11:18:57 +00:00
|
|
|
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()
|