"""Worktree lifecycle checks use real Git and the real agentctl registry.""" import fcntl import importlib.util import hashlib import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import time import unittest from unittest import mock SOURCE = Path(__file__).resolve().parents[1] class WorktreeTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) / "repo" self.root.mkdir() (self.root / "scripts").mkdir() for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl"): shutil.copy2(SOURCE / name, self.root / "scripts" / name) (self.root / ".gitignore").write_text(".worktrees/\n.locks/\nignored-evidence/\n") (self.root / "source.txt").write_text("base\n") self.env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} for name in ("AGENTCTL_RUN_ID", "AGENTCTL_TASK_ID"): self.env.pop(name, None) self.git("init", "-b", "dev") self.git("config", "user.email", "test@example.invalid") self.git("config", "user.name", "Test") self.git("add", ".") self.git("commit", "-qm", "fixture") self.base = self.git("rev-parse", "HEAD").stdout.strip() def run_cmd(self, args, cwd=None, env=None): return subprocess.run(args, cwd=cwd or self.root, env=env or self.env, text=True, capture_output=True, timeout=20) def git(self, *args, cwd=None): result = self.run_cmd(["git", *args], cwd=cwd) self.assertEqual(result.returncode, 0, result.stderr) return result def ctl(self, *args, cwd=None): return self.run_cmd([sys.executable, str(self.root / "scripts/agentctl.py"), *args], cwd=cwd) def init(self): result = self.ctl("init") self.assertEqual(result.returncode, 0, result.stderr) def helper(self, *args, script_root=None, cwd=None, env=None): script = (script_root or self.root) / "scripts/agent-worktree.sh" return self.run_cmd(["bash", str(script), *args], cwd=cwd, env=env) def create(self, branch="feature/task"): result = self.helper("create", branch) self.assertEqual(result.returncode, 0, result.stderr) return Path(json.loads(result.stdout)["worktree"]) def remove_with_visibility_fixture(self, *args): """Fixture only /proc enumeration; Git, registry and lock checks stay real. This host hides unrelated same-user daemon cwds. Positive removal tests model a fully visible process namespace containing the helper itself. Live cwd rejection below still runs the actual host process enumeration. """ code = ''' import importlib.util, os, sys from pathlib import Path from unittest import mock spec = importlib.util.spec_from_file_location("helper", sys.argv[1]) helper = importlib.util.module_from_spec(spec) spec.loader.exec_module(helper) original = Path.iterdir def visible_processes(path): if path == Path("/proc"): return iter([path / str(os.getpid())]) return original(path) sys.argv = sys.argv[1:] with mock.patch.object(Path, "iterdir", visible_processes): sys.exit(helper.main()) ''' return self.run_cmd([sys.executable, "-c", code, str(self.root / "scripts/agent_worktree.py"), "remove", *args]) def test_list_is_read_only_without_init(self): result = self.helper("list") self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(str(self.root), result.stdout) self.assertFalse((self.root / ".locks").exists()) self.assertFalse((self.root / ".worktrees").exists()) def test_write_requires_explicit_init(self): result = self.helper("create", "feature/task") self.assertNotEqual(result.returncode, 0) self.assertIn("NOT_INITIALIZED", result.stderr) self.assertFalse((self.root / ".worktrees").exists()) self.assertFalse((self.root / ".locks").exists()) def test_create_from_feature_uses_current_dev_and_canonical_main(self): self.init() first = self.create() self.git("commit", "--allow-empty", "-qm", "feature-only", cwd=first) result = self.helper("create", "fix/second", script_root=first, cwd=first / "scripts") self.assertEqual(result.returncode, 0, result.stderr) data = json.loads(result.stdout) self.assertEqual(data["base_dev_sha"], self.base) self.assertEqual(Path(data["worktree"]), self.root / ".worktrees/second") self.assertEqual(self.git("rev-parse", "fix/second").stdout.strip(), self.base) self.assertFalse((first / ".worktrees").exists()) self.assertFalse((first / ".locks").exists()) def test_create_with_main_on_unrelated_branch_uses_dev(self): self.init() self.git("checkout", "-b", "feature/legacy") self.git("commit", "--allow-empty", "-qm", "legacy-only") self.create() self.assertEqual(self.git("rev-parse", "feature/task").stdout.strip(), self.base) self.assertEqual(self.git("branch", "--show-current").stdout.strip(), "feature/legacy") def test_rejects_traversal_nested_and_protected_branches(self): self.init() for branch in ("dev", "main", "test", "feature/../escape", "feature/a/b", "fix/.hidden", "feature/-x"): with self.subTest(branch=branch): self.assertNotEqual(self.helper("create", branch).returncode, 0) self.assertFalse((self.root / ".worktrees").exists()) def test_same_task_id_and_existing_branch_do_not_collide(self): self.init() first = self.create() self.assertNotEqual(self.helper("create", "fix/task").returncode, 0) self.assertNotEqual(self.helper("create", "feature/task").returncode, 0) self.assertEqual(self.git("branch", "--show-current", cwd=first).stdout.strip(), "feature/task") def test_symlink_parent_or_target_is_refused(self): self.init() outside = Path(self.tmp.name) / "outside" outside.mkdir() parent = self.root / ".worktrees" parent.symlink_to(outside, target_is_directory=True) self.assertNotEqual(self.helper("create", "feature/task").returncode, 0) self.assertEqual(list(outside.iterdir()), []) parent.unlink() parent.mkdir() (parent / "task").symlink_to(outside, target_is_directory=True) self.assertNotEqual(self.helper("create", "feature/task").returncode, 0) self.assertEqual(list(outside.iterdir()), []) def test_stable_lock_refuses_concurrent_writer(self): self.init() lock = self.root / ".locks/os/worktrees.lock" with lock.open("a+") as holder: inode = lock.stat().st_ino fcntl.flock(holder.fileno(), fcntl.LOCK_EX) result = self.helper("create", "feature/task") self.assertNotEqual(result.returncode, 0) self.assertIn("busy", result.stderr) self.create() self.assertEqual(lock.stat().st_ino, inode) def test_forged_inherited_lease_is_refused(self): self.init() env = {**self.env, "AGENTCTL_RUN_ID": "forged", "AGENTCTL_TASK_ID": "task"} self.assertNotEqual(self.helper("create", "feature/task", env=env).returncode, 0) self.assertFalse((self.root / ".worktrees/task").exists()) def test_cleanup_legacy_mapping_and_nonforced_branch_delete_with_visibility_fixture(self): self.init() legacy = self.root / ".worktrees/feature-task" self.git("worktree", "add", "-b", "feature/task", str(legacy), self.base) result = self.remove_with_visibility_fixture("feature/task", "--delete-branch") self.assertEqual(result.returncode, 0, result.stderr) self.assertFalse(legacy.exists()) self.assertNotIn("refs/heads/feature/task", self.git("show-ref").stdout) def test_cleanup_refuses_unmerged_commit(self): self.init() task = self.create() self.git("commit", "--allow-empty", "-qm", "unique", cwd=task) result = self.helper("remove", "feature/task", "--delete-branch") self.assertNotEqual(result.returncode, 0) self.assertIn("ancestor", result.stderr) self.assertTrue(task.exists()) def test_branch_delete_refusal_preserves_branch_without_force_with_visibility_fixture(self): self.init() self.git("branch", "feature/legacy", self.base) self.git("commit", "--allow-empty", "-qm", "dev-next") task = self.create() self.git("checkout", "feature/legacy") result = self.remove_with_visibility_fixture("feature/task", "--delete-branch") self.assertNotEqual(result.returncode, 0) self.assertIn("branch retained", result.stderr) self.assertFalse(task.exists()) self.assertIn("refs/heads/feature/task", self.git("show-ref").stdout) def test_cleanup_refuses_tracked_untracked_and_ignored_evidence(self): self.init() task = self.create() for relative in ("source.txt", "new-evidence.txt", "ignored-evidence/only-copy.log"): with self.subTest(relative=relative): file = task / relative original = file.read_text() if file.exists() else None file.parent.mkdir(exist_ok=True) file.write_text("unique evidence\n") result = self.helper("remove", "feature/task", "--delete-branch") self.assertNotEqual(result.returncode, 0) self.assertTrue(file.exists()) self.assertEqual(file.read_text(), "unique evidence\n") if original is not None: file.write_text(original) else: file.unlink() def test_cleanup_refuses_live_process_cwd(self): self.init() task = self.create() process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=task) try: result = self.helper("remove", "feature/task") self.assertNotEqual(result.returncode, 0) self.assertIn(f"process {process.pid} still uses", result.stderr) self.assertTrue(task.exists()) finally: process.terminate() process.wait(timeout=5) def test_cleanup_refuses_unfinished_git_operation(self): self.init() task = self.create() marker = Path(self.git("rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD", cwd=task).stdout.strip()) marker.write_text(self.base + "\n") result = self.helper("remove", "feature/task") self.assertNotEqual(result.returncode, 0) self.assertIn("unfinished", result.stderr) self.assertTrue(task.exists()) def test_cleanup_actual_branch_mapping_with_visibility_fixture(self): self.init() first = self.create() other = self.root / ".worktrees/legacy-other" self.git("worktree", "add", "-b", "fix/task", str(other), self.base) result = self.remove_with_visibility_fixture("fix/task") self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(first.exists()) self.assertFalse(other.exists()) def test_inherited_worktree_lease_does_not_double_lock(self): self.init() task = self.create() result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts", "--capability", "git:worktrees", cwd=task) self.assertEqual(result.returncode, 0, result.stderr) result = self.ctl("with", "--task", "task", "--resource", "git:worktrees", "--cwd", str(self.root), "--", "bash", str(self.root / "scripts/agent-worktree.sh"), "create", "fix/child", cwd=task) self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue((self.root / ".worktrees/child").exists()) def test_cleanup_lifecycle_lock_prevents_a_new_run(self): self.init() task = self.create() result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts", cwd=task) self.assertEqual(result.returncode, 0, result.stderr) key = hashlib.sha256(str(task.resolve()).encode()).hexdigest() lock = self.root / ".locks/os" / f"worktree-{key}.lock" marker = Path(self.tmp.name) / "should-not-exist" with lock.open("a+") as holder: fcntl.flock(holder.fileno(), fcntl.LOCK_EX) result = self.ctl("with", "--task", "task", "--resource", "path:scripts", "--wait", "0", "--", sys.executable, "-c", "from pathlib import Path; import sys; Path(sys.argv[1]).touch()", str(marker)) self.assertNotEqual(result.returncode, 0) self.assertFalse(marker.exists()) def test_cleanup_refuses_active_registry_operation_even_with_other_cwd(self): self.init() task = self.create() result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts", cwd=task) self.assertEqual(result.returncode, 0, result.stderr) ready = Path(self.tmp.name) / "ready" child_code = "from pathlib import Path; import os,time,sys; os.chdir(sys.argv[2]); Path(sys.argv[1]).touch(); time.sleep(30)" proc = subprocess.Popen([sys.executable, str(self.root / "scripts/agentctl.py"), "with", "--task", "task", "--resource", "path:scripts", "--", sys.executable, "-c", child_code, str(ready), str(self.root)], cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: deadline = time.monotonic() + 5 while not ready.exists() and proc.poll() is None and time.monotonic() < deadline: time.sleep(0.02) self.assertTrue(ready.exists(), "supervised operation did not start") result = self.helper("remove", "feature/task") self.assertNotEqual(result.returncode, 0) self.assertTrue(task.exists()) self.assertNotIn("still uses", result.stderr) self.assertRegex(result.stderr, "active registered operation|live or unresolved operation") finally: proc.terminate() proc.communicate(timeout=8) class ProcessVisibilityTest(unittest.TestCase): def test_unknown_same_uid_inaccessible_cwd_is_not_skipped(self): spec = importlib.util.spec_from_file_location("agent_worktree_under_test", SOURCE / "agent_worktree.py") helper = importlib.util.module_from_spec(spec) spec.loader.exec_module(helper) with tempfile.TemporaryDirectory() as temp: proc = Path(temp) / "987654321" proc.mkdir() (proc / "comm").write_text("coding-agent\n") with mock.patch.object(helper.Path, "iterdir", return_value=iter([proc])), \ mock.patch.object(helper.os, "readlink", side_effect=PermissionError): with self.assertRaisesRegex(helper.Refused, "cannot verify cwd"): helper.assert_no_process_cwd(Path(temp) / "task") if __name__ == "__main__": unittest.main()