import subprocess import unittest from test_cli import TOML, Cli from test_claims import git from test_merge import IDENT class FinishTest(Cli): def setUp(self): super().setUp() git(self.root, "init", "-q", "-b", "master") (self.root / ".gitignore").write_text(".worktrees/\n.wf/\n") git(self.root, "add", "-A") git(self.root, "commit", "-qm", "init") self.wt = self.root / ".worktrees" / "sonnet" git(self.root, "worktree", "add", "-q", str(self.wt), "-b", "sonnet/t-three") def out(self, *args, cwd=None): return subprocess.run(["git", *args], cwd=cwd or self.root, capture_output=True, text=True).stdout def finish(self, *args, cwd=None): return self.wf("finish", *args, project=False, cwd=cwd or self.wt, env=IDENT) def write_code(self): (self.wt / "code.txt").write_text("x\n") def test_report_line_tool_commit(self): self.write_code() code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push", "--tool-commit", "abc1234") self.assertEqual(code, 0, err) self.assertRegex(out, r"report: commit [0-9a-f]+ tool abc1234\n$") def test_report_line_no_worktree(self): (self.root / "code.txt").write_text("x\n") code, out, err = self.wf("finish", "t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push", project=False, cwd=self.root, env=IDENT) self.assertEqual(code, 0, err) sha = self.out("rev-parse", "--short", "HEAD").strip() self.assertTrue(out.endswith(f"report: commit {sha}\n"), out) def test_commit_done_merge_in_one(self): self.write_code() code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertIn("done: t-three → tasks/archive.md\n", out) self.assertIn("committed code.txt\n", out) self.assertIn("merged sonnet/t-three into master\n", out) sha = self.out("log", "--format=%h", "-n1", "--grep=^impl$", "master").strip() self.assertTrue(out.endswith(f"report: commit {sha}\n"), out) self.assertNotIn("wf merge", out) # no merge-step reminder: finish merged self.assertEqual(self.out("log", "--format=%s", "master"), "t-three done\nimpl\ninit\n") self.assertEqual(self.out("status", "--porcelain"), "") self.assertEqual((self.root / "code.txt").read_text(), "x\n") self.assertIn("t-three", (self.root / "tasks" / "archive.md").read_text()) def test_wip_commits_notes_clears(self): self.write_code() self.wf("status", "t-three", "progress", "br", project=False, cwd=self.wt, env=IDENT) code, out, err = self.wf("wip", "t-three", "-m", "half done, next: tests", "--commit", "wip", "code.txt", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, err) self.assertIn("committed code.txt", out) self.assertEqual(self.out("log", "--format=%s", "-n1", cwd=self.wt), "wip\n") self.assertEqual(self.out("status", "--porcelain", cwd=self.wt, ).count("code.txt"), 0) code, out, err = self.wf("show", "t-three", project=False, cwd=self.wt) self.assertIn("half done, next: tests", out) self.assertNotIn("in progress", out) def test_no_commit_bookkeeping_only(self): git(self.wt, "switch", "-q", "--detach", "master") code, out, err = self.finish("t-three", "-m", "ok", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertTrue(out.endswith("committed TASKS.md tasks/archive.md\n"), out) self.assertEqual(self.out("log", "--format=%s", "master"), "bookkeeping\ninit\n") def test_commit_tasks_only_no_path_lists_once(self): code, out, err = self.finish("t-three", "-m", "ok", "--commit", "bk", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertEqual(out.count("TASKS.md"), 1, out) self.assertIn("committed TASKS.md tasks/archive.md\n", out) self.assertEqual(self.out("log", "--format=%s", "master"), "t-three done\ninit\n") def test_commit_tasks_path_named_lists_once(self): code, out, err = self.finish("t-three", "-m", "ok", "--commit", "bk", "TASKS.md", "--no-push", cwd=self.root) self.assertEqual((code, err), (0, ""), out) self.assertEqual(out.count("TASKS.md"), 1, out) self.assertEqual(self.out("log", "--format=%s", "master"), "bk\ninit\n") def test_new_untracked_dir_file_path_accepted(self): (self.wt / "docs" / "notes").mkdir(parents=True) (self.wt / "docs" / "notes" / "SCOPE.md").write_text("s\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "docs/notes/SCOPE.md", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertIn("docs/notes/SCOPE.md", self.out("show", "--stat", "--format=", "master~1")) def test_dirty_outside_paths_refused_before_done(self): self.write_code() (self.wt / "DESIGN.md").write_text("stray\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push") self.assertEqual((code, err), (1, "wf: uncommitted changes outside the --commit paths: DESIGN.md\n")) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) self.assertEqual(self.out("log", "--format=%s", "master"), "init\n") def test_gate_red_refused_before_done(self): (self.root / "workflow.toml").write_text(TOML + 'quick_gate = ["exit 3"]\n') git(self.root, "commit", "-qam", "gate") git(self.wt, "rebase", "-q", "master") code, out, err = self.finish("t-three", "-m", "ok", "--no-push") self.assertEqual(code, 1, out + err) self.assertIn("quick_gate 'exit 3' red", err) self.assertIn("wf add -p 0", err) self.assertIn("done+gate-red", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) def test_paths_need_commit_message(self): code, out, err = self.finish("t-three", "-m", "ok", "code.txt") self.assertEqual(code, 2, out + err) self.assertIn("paths need --commit", err) def test_main_tree_commits_code_and_bookkeeping(self): (self.root / "code.txt").write_text("y\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", cwd=self.root) self.assertEqual((code, err), (0, ""), out) self.assertEqual(self.out("log", "--format=%s", "master"), "impl\ninit\n") self.assertEqual(self.out("show", "--stat", "--format=", "master").split("|")[0].strip(), "TASKS.md") self.assertEqual(self.out("status", "--porcelain"), "") def test_main_tree_pushes_home(self): bare = self.root.parent / "home-finish.git" subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True) git(self.root, "remote", "add", "home", str(bare)) (self.root / "code.txt").write_text("y\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", cwd=self.root) self.assertEqual((code, err), (0, ""), out) self.assertIn("pushed home\n", out) self.assertEqual(self.out("log", "--format=%s", "master", cwd=bare), "impl\ninit\n") def test_main_tree_no_push_flag(self): bare = self.root.parent / "home-finish2.git" subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True) git(self.root, "remote", "add", "home", str(bare)) (self.root / "code.txt").write_text("y\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push", cwd=self.root) self.assertEqual((code, err), (0, ""), out) self.assertNotIn("pushed home", out) # books stay with the task: no half-done finish, no main-tree done of a worktree's task def test_path_outside_repo_refused_before_done(self): other = self.root.parent / "other-repo-file.md" other.write_text("x\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", str(other), "--no-push") self.assertEqual(code, 1, out + err) self.assertIn("is outside this repo", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) self.assertEqual(self.out("status", "--porcelain"), "") def test_missing_path_refused_before_done(self): code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "shared/CLAUDE.md", "--no-push") self.assertEqual(code, 1, out + err) self.assertIn("does not exist", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) def test_unchanged_paths_refused_before_done(self): code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "workflow.toml", "--no-push") self.assertEqual(code, 1, out + err) self.assertIn("nothing to commit", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) def test_worktree_books_copy_refused(self): (self.wt / "TASKS.md").write_text((self.wt / "TASKS.md").read_text() + "\n") code, out, err = self.finish("t-three", "-m", "ok", "--commit", "bk", "TASKS.md", "--no-push") self.assertEqual(code, 1, out + err) self.assertIn("copy of the books", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) def test_already_done_resumes_commit_and_merge(self): self.write_code() code, out, err = self.wf("done", "t-three", "-m", "ok", project=False, cwd=self.wt, env=IDENT) self.assertEqual(code, 0, err) code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertIn("t-three already done (tasks/archive.md): resuming", out) self.assertEqual(self.out("log", "--format=%s", "master"), "t-three done\nimpl\ninit\n") self.assertEqual(self.out("status", "--porcelain"), "") self.assertEqual((self.root / "tasks" / "archive.md").read_text().count("**t-three**"), 1) def test_main_tree_done_of_worktree_task_refused(self): self.wf("status", "t-three", "progress", "sonnet/t-three", project=False, cwd=self.wt, env=IDENT) for cmd in (["done", "t-three", "-m", "ok"], ["finish", "t-three", "-m", "ok", "--no-push"]): code, out, err = self.wf(*cmd, project=False, cwd=self.root, env=IDENT) self.assertEqual(code, 1, out + err) self.assertIn("in progress in worktree", err) self.assertIn("branch sonnet/t-three", err) self.assertIn("t-three", (self.root / "TASKS.md").read_text()) # from the worktree it goes through, books committed on master by the merge self.write_code() code, out, err = self.finish("t-three", "-m", "ok", "--commit", "impl", "code.txt", "--no-push") self.assertEqual((code, err), (0, ""), out) self.assertEqual(self.out("log", "--format=%s", "master"), "t-three done\nimpl\ninit\n") def test_main_tree_done_after_status_clear(self): self.wf("status", "t-three", "progress", "sonnet/t-three", project=False, cwd=self.wt, env=IDENT) self.wf("status", "t-three", "clear", project=False, cwd=self.root, env=IDENT) code, out, err = self.wf("done", "t-three", "-m", "ok", project=False, cwd=self.root, env=IDENT) self.assertEqual(code, 0, out + err) def test_main_tree_done_branch_without_worktree_ok(self): self.wf("status", "t-three", "progress", "fast/t-three", project=False, cwd=self.root, env=IDENT) code, out, err = self.wf("done", "t-three", "-m", "ok", project=False, cwd=self.root, env=IDENT) self.assertEqual(code, 0, out + err) if __name__ == "__main__": unittest.main()