workflow

git clone https://git.godosa.eu/workflow

master

raw ยท 18682 bytes

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 <lane>/<id> 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 <task> --worktree {self.code / '.worktrees' / 'fast'} --branch fast/<task>  "
                      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)