import os import subprocess import unittest from pathlib import Path from test_cli import TOML, Cli from test_claims import git from test_merge import IDENT class SplitCli(Cli): """Private project (self.root, git, workflow.toml with code_root) + sibling code repo (self.code).""" toml = TOML + 'code_root = "../code"\n' def setUp(self): super().setUp() git(self.root, "init", "-q", "-b", "master") (self.root / ".gitignore").write_text(".wf/\nout/\n") git(self.root, "add", "-A") git(self.root, "commit", "-qm", "init") self.code = self.root.parent / "code" self.code.mkdir() git(self.code, "init", "-q", "-b", "master") (self.code / ".gitignore").write_text(".worktrees/\n.wf-home\n") (self.code / "lib.txt").write_text("a\n") git(self.code, "add", "-A") git(self.code, "commit", "-qm", "init") def add_wt(self, lane="sonnet", id="t-three"): """Code worktree on branch / with .wf-home (what wf start makes).""" wt = self.code / ".worktrees" / lane git(self.code, "worktree", "add", "-q", str(wt), "-b", f"{lane}/{id}") (wt / ".wf-home").write_text(f"{self.root}\n") return wt def log(self, repo, rev="master"): return subprocess.run(["git", "log", "--format=%s", rev], cwd=repo, capture_output=True, text=True).stdout def sha(self, repo, rev="master"): return subprocess.run(["git", "rev-parse", "--short", rev], cwd=repo, capture_output=True, text=True).stdout.strip() class DiscoveryTest(SplitCli): def test_wf_in_code_worktree_finds_private_project(self): wt = self.add_wt() code, out, err = self.wf("show", "t-three", project=False, cwd=wt) self.assertEqual(code, 0, err) self.assertIn("**t-three**", out) def test_dangling_wf_home_is_config_error(self): wt = self.add_wt() (wt / ".wf-home").write_text(f"{self.root.parent / 'gone'}\n") code, out, err = self.wf("show", "t-three", project=False, cwd=wt) self.assertNotEqual(code, 0) self.assertIn(".wf-home points to", err) self.assertNotIn("Traceback", err) def test_split_and_code_main(self): import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from wflib import config cfg = config.load(self.root) self.assertTrue(cfg.split) self.assertEqual(config.code_main(cfg), self.code) self.assertEqual(cfg.push, []) def test_code_root_inside_same_repo_not_split(self): import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from wflib import config (self.root / "workflow.toml").write_text(TOML + 'code_root = "."\npush = ["true"]\n') cfg = config.load(self.root) self.assertFalse(cfg.split) self.assertIsNone(config.code_main(cfg)) self.assertEqual(cfg.push, ["true"]) class StartSplitTest(SplitCli): toml = SplitCli.toml + 'worktree_setup = ["pwd > where.txt"]\n' def test_start_makes_code_worktree_with_wf_home(self): wt = self.code / ".worktrees" / "slow" code, out, err = self.wf("start", "t-three", "--worktree", str(wt), "--branch", "slow/t-three", project=False, env=IDENT) self.assertEqual(code, 0, out + err) self.assertEqual((wt / ".wf-home").read_text(), f"{self.root}\n") self.assertEqual(subprocess.run(["git", "branch", "--show-current"], cwd=wt, capture_output=True, text=True).stdout.strip(), "slow/t-three") self.assertEqual((wt / "where.txt").read_text().strip(), str(wt)) # setup cwd = code worktree self.assertIn("(in progress: slow/t-three)", (self.root / "TASKS.md").read_text()) self.assertIn(f"cd {wt} && (make test) && ", out) self.assertFalse((self.root / ".worktrees").exists()) # nothing in the private repo def test_start_refuses_private_repo_worktree(self): wt = self.root / ".worktrees" / "slow" git(self.root, "worktree", "add", "-q", str(wt), "-b", "slow/old") code, out, err = self.wf("start", "t-three", "--worktree", str(wt), "--branch", "slow/t-three", project=False, env=IDENT) self.assertNotEqual(code, 0) self.assertIn("is not a worktree of", err) self.assertFalse((wt / ".wf-home").exists()) self.assertEqual(subprocess.run(["git", "branch", "--show-current"], cwd=wt, capture_output=True, text=True).stdout.strip(), "slow/old") # not switched self.assertIn("- **t-three** [P2] (<1h): Third", (self.root / "TASKS.md").read_text()) # no progress def test_start_reuses_existing_code_worktree(self): wt = self.code / ".worktrees" / "slow" git(self.code, "worktree", "add", "-q", str(wt), "-b", "slow/old") code, out, err = self.wf("start", "t-three", "--worktree", str(wt), "--branch", "slow/t-three", project=False, env=IDENT) self.assertEqual(code, 0, out + err) self.assertEqual((wt / ".wf-home").read_text(), f"{self.root}\n") def test_setup_in_code_worktree(self): wt = self.add_wt() code, out, err = self.wf("setup", project=False, cwd=wt) self.assertEqual(code, 0, err) self.assertEqual((wt / "where.txt").read_text().strip(), str(wt)) def env(self, name, pid): # copy of tests/test_claims.py ClaimTest.env sock = self.root / f"{name}.sock" sock.write_text("") return {"CLAUDE_CODE_MESSAGING_SOCKET": str(sock), "CLAUDE_PID": str(pid), "CLAUDE_CODE_SESSION_ID": name} def test_next_advice_names_wf_start_in_code_repo(self): # pattern: tests/test_claims.py::test_next_says_worktree_mode_when_two_sessions_live son, me = self.env("s", os.getppid()), self.env("o", os.getpid()) self.wf("next", "--lane", "slow", "--as", "sonnet", "--brief", env=son) code, out, err = self.wf("next", "--lane", "fast", "--as", "opus", env=me) self.assertIn(f" wf start --worktree {self.code / '.worktrees' / 'fast'} --branch fast/ " f"(run in {self.root}; wf there writes this TASKS.md)\n", out) class FinishSplitTest(SplitCli): def setUp(self): super().setUp() self.wt = self.add_wt() def finish(self, *args): return self.wf("finish", *args, "--no-push", project=False, cwd=self.wt, env=IDENT) def test_code_only(self): (self.wt / "code.txt").write_text("x\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt") self.assertEqual(code, 0, out + err) self.assertEqual(self.log(self.code), "impl\ninit\n") csha = self.sha(self.code) self.assertEqual(self.log(self.root), f"t-three done (code {csha})\ninit\n") self.assertTrue(out.endswith(f"report: commit {csha} books {self.sha(self.root)}\n"), out) self.assertEqual(subprocess.run(["git", "branch", "--list", "sonnet/t-three"], cwd=self.code, capture_output=True, text=True).stdout, "") self.assertIn("t-three", (self.root / "tasks" / "archive.md").read_text()) def test_code_and_private_path(self): (self.wt / "code.txt").write_text("x\n") (self.root / "docs" / "notes.md").write_text("n\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", str(self.root / "docs" / "notes.md")) self.assertEqual(code, 0, out + err) self.assertEqual(self.log(self.code), "impl\ninit\n") self.assertEqual(self.log(self.root), f"impl (code {self.sha(self.code)})\ninit\n") self.assertIn("docs/notes.md", subprocess.run(["git", "show", "--stat", "HEAD"], cwd=self.root, capture_output=True, text=True).stdout) def test_private_only(self): (self.root / "docs" / "notes.md").write_text("n\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "areas", str(self.root / "docs" / "notes.md")) self.assertEqual(code, 0, out + err) self.assertEqual(self.log(self.code), "init\n") # no empty code commit self.assertEqual(self.log(self.root), "areas\ninit\n") self.assertTrue(out.endswith(f"report: commit - books {self.sha(self.root)}\n"), out) self.assertEqual(subprocess.run(["git", "branch", "--list", "sonnet/t-three"], cwd=self.code, capture_output=True, text=True).stdout, "") def test_unignored_wf_home_does_not_block(self): (self.code / ".gitignore").write_text(".worktrees/\n") git(self.code, "commit", "-qam", "no wf-home ignore") git(self.wt, "rebase", "-q", "master") (self.wt / "code.txt").write_text("x\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt") self.assertEqual(code, 0, out + err) self.assertNotIn(".wf-home", subprocess.run(["git", "show", "--stat", "master"], cwd=self.code, capture_output=True, text=True).stdout) def test_stray_code_file_refused_task_open(self): (self.wt / "code.txt").write_text("x\n") (self.wt / "stray.txt").write_text("s\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt") self.assertNotEqual(code, 0) self.assertIn("stray.txt", err) self.assertIn("- **t-three**", (self.root / "TASKS.md").read_text()) def test_path_in_neither_repo_refused(self): other = self.root.parent / "elsewhere.txt" other.write_text("e\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", str(other)) self.assertNotEqual(code, 0) self.assertIn("in neither repo", err) def test_rebase_conflict_commits_nothing_private(self): (self.wt / "lib.txt").write_text("branch\n") (self.code / "lib.txt").write_text("master\n") git(self.code, "commit", "-qam", "moved") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "lib.txt") self.assertNotEqual(code, 0) self.assertIn("conflicts", err) self.assertEqual(self.log(self.root), "init\n") # books not committed yet (self.wt / "lib.txt").write_text("both\n") # resolve by hand as the worker would git(self.wt, "reset", "-q", "--soft", "master") git(self.wt, "commit", "-qam", "impl") code, out, err = self.wf("merge", "--no-push", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, out + err) self.assertTrue(self.log(self.root).startswith("t-three done (code "), self.log(self.root)) def test_wip_commits_both(self): (self.wt / "code.txt").write_text("x\n") (self.root / "docs" / "notes.md").write_text("n\n") code, out, err = self.wf("wip", "t-three", "-m", "half", "--commit", "wip", "code.txt", str(self.root / "docs" / "notes.md"), project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, out + err) self.assertEqual(self.log(self.code, "sonnet/t-three"), "wip\ninit\n") self.assertEqual(self.log(self.root), "t-three wip: half\ninit\n") def test_done_lines_name_private_books(self): code, out, err = self.wf("done", "t-three", "-m", "ok", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, out + err) self.assertIn("commit TASKS.md tasks/archive.md, push home", out) class PushSplitTest(SplitCli): toml = SplitCli.toml + 'push = ["echo pushing", "echo boom; exit 3"]\n' def setUp(self): super().setUp() self.wt = self.add_wt() (self.wt / "code.txt").write_text("x\n") def test_push_failure_keeps_merge_marks_and_reports(self): code, out, err = self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, out + err) self.assertIn("not pushed (exit 3): echo boom; exit 3\n", out) self.assertIn(" boom\n", out) self.assertIn("rerun: wf push", out) self.assertRegex(out, r"report: commit [0-9a-f]+ books [0-9a-f]+ push-failed 3\n$") self.assertEqual(self.log(self.code), "impl\ninit\n") self.assertTrue((self.root / ".wf" / "push-failed").is_file()) code, out, err = self.wf("next", "--brief", project=False, cwd=self.root) self.assertIn("push failed", out) def test_wf_push_success_clears_marker(self): self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", project=False, cwd=self.wt, env=IDENT) (self.root / "workflow.toml").write_text(SplitCli.toml + 'push = ["true"]\n') code, out, err = self.wf("push", project=False, cwd=self.root) self.assertEqual(code, 0, out + err) self.assertFalse((self.root / ".wf" / "push-failed").exists()) def test_push_runs_in_private_root(self): (self.root / "workflow.toml").write_text(SplitCli.toml + 'push = ["pwd > pushed-from.txt"]\n') code, out, err = self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, out + err) self.assertEqual((self.root / "pushed-from.txt").read_text().strip(), str(self.root)) class OrchSplitTest(SplitCli): def setUp(self): super().setUp() self.wf("set", "t-three", "--model", "sonnet", "--done", "x") def test_pick_worktree_in_code_repo(self): code, out, err = self.wf("orch", "pick", "fast", "--id", "t-three", project=False, env=IDENT) self.assertEqual(code, 0, out + err) self.assertIn(f"Main tree: {self.root} Worktree: {self.code / '.worktrees' / 'fast'} Branch: fast/t-three", out) def test_post_done_checks_code_branch_and_stops_on_push_failed(self): self.wf("orch", "pick", "fast", "--id", "t-three", project=False, env=IDENT) wt = self.code / ".worktrees" / "fast" self.wf("start", "t-three", "--worktree", str(wt), "--branch", "fast/t-three", project=False, env=IDENT) (wt / "code.txt").write_text("x\n") (self.root / "workflow.toml").write_text(SplitCli.toml + 'push = ["exit 1"]\n') code, out, err = self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", project=False, cwd=wt, env=IDENT) self.assertEqual(code, 0, out + err) code, out, err = self.wf("orch", "post", "t-three", "fast", "--result", "done", project=False, env=IDENT) self.assertEqual(code, 0, out + err) self.assertIn("post: t-three push-failed", out) self.assertIn("stop lane fast: push-failed", out) self.assertNotIn("branch fast/t-three still there", out) self.assertIn(f" {self.sha(self.code)} ", (self.root / "out" / "wf-orch.log").read_text()) def _finish_then_dirty_books(self): self.wf("orch", "pick", "fast", "--id", "t-three", project=False, env=IDENT) wt = self.code / ".worktrees" / "fast" self.wf("start", "t-three", "--worktree", str(wt), "--branch", "fast/t-three", project=False, env=IDENT) (wt / "code.txt").write_text("x\n") code, out, err = self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", project=False, cwd=wt, env=IDENT) self.assertEqual(code, 0, out + err) self.wf("note", "t-one", "later edit", project=False, env=IDENT) # books dirty in the private tree self.assertTrue(subprocess.run(["git", "status", "--porcelain", "TASKS.md"], cwd=self.root, capture_output=True, text=True).stdout.strip()) def test_post_done_commits_leftover_books_with_code_sha(self): self._finish_then_dirty_books() csha = self.sha(self.code) code, out, err = self.wf("orch", "post", "t-three", "fast", "--result", "done", "--commit", csha, "--no-pick", project=False, env=IDENT) self.assertEqual(code, 0, out + err) self.assertEqual(self.log(self.root).splitlines()[:2], [f"t-three done (code {csha})"] * 2) self.assertFalse(subprocess.run(["git", "status", "--porcelain", "TASKS.md"], cwd=self.root, capture_output=True, text=True).stdout.strip()) self.assertIn(f" t-three done {csha} ", (self.root / "out" / "wf-orch.log").read_text()) def test_post_stale_private_sha_replaced_by_code_sha(self): self._finish_then_dirty_books() stale = self.sha(self.root) # worker reported the private repo's HEAD code, out, err = self.wf("orch", "post", "t-three", "fast", "--result", "done", "--commit", stale, "--no-pick", project=False, env=IDENT) self.assertEqual(code, 0, out + err) csha = self.sha(self.code) self.assertIn(f"commit {stale} not in code repo", out) self.assertIn(f" t-three done {csha} ", (self.root / "out" / "wf-orch.log").read_text()) self.assertEqual(self.log(self.root).splitlines()[:2], [f"t-three done (code {csha})"] * 2) self.assertFalse(subprocess.run(["git", "status", "--porcelain", "TASKS.md"], cwd=self.root, capture_output=True, text=True).stdout.strip()) class CheckSplitTest(SplitCli): def test_cloud_error_push_and_ignore_warnings(self): (self.root / "workflow.toml").write_text(SplitCli.toml + "cloud = true\n") (self.code / ".gitignore").write_text(".worktrees/\n") git(self.code, "commit", "-qam", "no wf-home ignore") code, out, err = self.wf("check") self.assertEqual(code, 1) self.assertIn("cloud lane needs a single repo", out) self.assertIn("split project without push", out) self.assertIn(".wf-home not git-ignored", out) def test_clean_split_project(self): (self.root / "workflow.toml").write_text(SplitCli.toml + 'push = ["true"]\n') code, out, err = self.wf("check") self.assertEqual(code, 0, out) self.assertNotIn("split", out)