diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 09:24:14 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 09:24:14 +0200 |
| commit | ecf39ec27307f36d9458a22a332c8848bffe79a5 (patch) | |
| tree | 46a7fec1f1312c286591946b1355a45dd9404cf4 | |
| parent | df72f6f16a7a5141ff4df1bab7e1de34207a4163 (diff) | |
| download | workflow-ecf39ec27307f36d9458a22a332c8848bffe79a5.tar.gz workflow-ecf39ec27307f36d9458a22a332c8848bffe79a5.zip | |
split projects 3/6: wf finish/merge/wip across code + private repo (report: commit <code> books <private>)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
| -rw-r--r-- | CHANGES.md | 1 | ||||
| -rw-r--r-- | tests/test_split.py | 86 | ||||
| -rwxr-xr-x | wf.py | 75 |
3 files changed, 145 insertions, 17 deletions
@@ -1,4 +1,5 @@ # Changes (newest first) +- 2026-10-07 split projects 3/6: `wf finish` / `wf merge` / `wf wip` across code + private repo: paths under the private root are committed there (with TASKS/archive, message `<msg> (code <sha>)`), code paths on the branch; no code paths → no code commit; report `commit <code|-> books <private>`; `wf done` merge advice names the private books. Projects: nothing. - 2026-10-07 split projects 2/6: `wf start` makes code worktrees (branch in the code repo, `.wf-home` pointing at the private project); `wf next` multi-session advice names `wf start` in the code repo. Projects: nothing. - 2026-10-07 split projects 1/6: Config.split, push key, .wf-home discovery. Projects: nothing. - 2026-10-07 Shared push rule: in a split project the public `code_root` repo is pushed only via the project's publish command, never `git push home --all` (that pushes local review branches unscanned). Projects: split projects name their publish command in their CLAUDE.md. diff --git a/tests/test_split.py b/tests/test_split.py index 346c138..d382a9f 100644 --- a/tests/test_split.py +++ b/tests/test_split.py @@ -109,3 +109,89 @@ class StartSplitTest(SplitCli): 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_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) @@ -1194,7 +1194,8 @@ def worktree_done_lines(p: Project, args) -> list[str]: return [] branch, main = config.git_branch(wt[1]), wt[2] master = config.git_branch(main / ".git") or "master" - files = " ".join(str(f.relative_to(main)) for f in (p.cfg.tasks, p.cfg.archive)) + bmain = config.git_top(p.cfg.root) or main # books repo (private repo of a split project) + files = " ".join(str(f.relative_to(bmain)) for f in (p.cfg.tasks, p.cfg.archive)) warn = [] if not branch and (n := unmerged_count(wt[0], master)): warn = [f"detached HEAD has {n} commit{'s' * (n != 1)} not in {master}: wf merge merges " @@ -1224,14 +1225,19 @@ def cmd_merge(args) -> int: branch = config.git_branch(gitdir) master = config.git_branch(main / ".git") or "master" cfg = config.load_at(start) - files = [str(f.relative_to(main)) for f in (cfg.tasks, cfg.archive)] + bmain = config.git_top(cfg.root) or main # books repo: the private repo of a split project, else main + private = [str(Path(x).resolve().relative_to(bmain.resolve())) for x in getattr(args, "private", None) or []] + files = [str(f.relative_to(bmain)) for f in (cfg.tasks, cfg.archive)] + private if git_run(top, "status", "--porcelain", "-uall").stdout.strip(): raise Failure("worktree has uncommitted changes: commit them first") - if not branch and not unmerged_count(top, master): + had = unmerged_count(top, master) + if not branch and not had: # after a merge: notes / follow-ups written since → bookkeeping commit only - if not git_run(main, "status", "--porcelain", "--", *files).stdout.strip(): + if not git_run(bmain, "status", "--porcelain", "--", *files).stdout.strip(): raise Failure("worktree is on a detached HEAD: nothing to merge") - r = git_run(main, "commit", "-q", "-m", args.m or "bookkeeping", "--", *files) + if private and (r := git_run(bmain, "add", "--", *private)).returncode: + raise Failure(f"git add failed: {(r.stderr.strip() or 'git error').splitlines()[-1]}") + r = git_run(bmain, "commit", "-q", "-m", args.m or "bookkeeping", "--", *files) if r.returncode: raise Failure(f"bookkeeping commit failed: {(r.stderr.strip() or r.stdout.strip() or 'git error').splitlines()[-1]}") print(f"committed {' '.join(files)}") @@ -1241,21 +1247,28 @@ def cmd_merge(args) -> int: git_run(top, "rebase", "--abort") raise Failure(f"rebase onto {master} conflicts: git rebase {master}, resolve, verify, then wf merge again") out.append(f"rebased onto {master}") - args.merged_sha = git_run(top, "rev-parse", "--short", "HEAD").stdout.strip() + args.merged_sha = (git_run(top, "rev-parse", "--short", "HEAD").stdout.strip() if had or bmain == main + else "-") r = git_run(main, "merge", "--ff-only", branch or git_run(top, "rev-parse", "HEAD").stdout.strip()) if r.returncode: raise Failure(f"ff-merge into {master} failed: {(r.stderr.strip() or 'git error').splitlines()[-1]}") out.append(f"fast-forwarded {master}") - if git_run(main, "status", "--porcelain", "--", *files).stdout.strip(): - msg = args.m or (f"{branch.rsplit('/', 1)[-1]} done" if branch else "bookkeeping") - r = git_run(main, "commit", "-q", "-m", msg, "--", *files) + if private and (r := git_run(bmain, "add", "--", *private)).returncode: + raise Failure(f"git add failed: {(r.stderr.strip() or 'git error').splitlines()[-1]}") + if git_run(bmain, "status", "--porcelain", "--", *files).stdout.strip(): + msg = (getattr(args, "private_msg", None) if private else None) or args.m \ + or (f"{branch.rsplit('/', 1)[-1]} done" if branch else "bookkeeping") + if bmain != main and args.merged_sha != "-": + msg += f" (code {args.merged_sha})" + r = git_run(bmain, "commit", "-q", "-m", msg, "--", *files) if r.returncode: raise Failure(f"bookkeeping commit failed: {(r.stderr.strip() or r.stdout.strip() or 'git error').splitlines()[-1]}") out.append(f"committed {' '.join(files)}") + args.books_sha = git_run(bmain, "rev-parse", "--short", "HEAD").stdout.strip() git_run(top, "switch", "-q", "--detach", master) if branch: git_run(top, "branch", "-q", "-d", branch) - if not args.no_push and _push_home(main): + if not args.no_push and any([_push_home(main), bmain != main and _push_home(bmain)]): out.append("pushed home") out.append(f"merged {branch or 'detached HEAD'} into {master}") print("\n".join(out)) @@ -1304,15 +1317,28 @@ def cmd_finish(args) -> int: top = Path(git_run(start, "rev-parse", "--show-toplevel").stdout.strip() or start) paths = [(start / f).resolve() for f in args.paths] books = [] if wt else [cfg.tasks.resolve(), cfg.archive.resolve()] + btop = (config.git_top(cfg.root) or cfg.root).resolve() + split = bool(wt) and btop != wt[2].resolve() # code worktree of a split project: books in another repo + proot, priv, code = cfg.root.resolve(), [], [] for f, path in zip(args.paths, paths): + if split and (path == proot or proot in path.parents): + if not path.exists() and git_run(btop, "ls-files", "--error-unmatch", "--", str(path)).returncode: + raise Failure(f"path {f} does not exist and is not tracked: nothing to commit") + priv.append(path) + continue + code.append(path) if path != top and top not in path.parents: + if split: + raise Failure(f"path {f} is in neither repo ({top}, {cfg.root}): commit it in its own repo " + "first, then wf finish without it") raise Failure(f"path {f} is outside this repo ({top}): commit it in its own repo first, " "then wf finish without it") - if wt and path in (top / cfg.tasks.relative_to(wt[2]), top / cfg.archive.relative_to(wt[2])): + if wt and not split and path in (top / cfg.tasks.relative_to(wt[2]), top / cfg.archive.relative_to(wt[2])): raise Failure(f"path {f} = this worktree's copy of the books: wf finish writes the main tree's " "and wf merge commits them; drop it") if not path.exists() and git_run(top, "ls-files", "--error-unmatch", "--", str(path)).returncode: raise Failure(f"path {f} does not exist and is not tracked: nothing to commit") + paths = code if wt and paths and not git_run(top, "status", "--porcelain", "--", *map(str, paths)).stdout.strip(): raise Failure("nothing to commit in the --commit paths: drop them (wf finish without paths) or fix them") if stray := _dirty_outside(top, paths + books): @@ -1348,11 +1374,13 @@ def cmd_finish(args) -> int: if not wt and not args.no_push and _push_home(top): print("pushed home", flush=True) if wt: - ns = argparse.Namespace(project=args.project, m=None, no_push=args.no_push) + ns = argparse.Namespace(project=args.project, m=None, no_push=args.no_push, + private=priv, private_msg=args.commit) rc = cmd_merge(ns) if not rc and getattr(ns, "merged_sha", ""): tool = getattr(args, "tool_commit", None) - print(f"report: commit {ns.merged_sha}" + (f" tool {tool}" if tool else ""), flush=True) + books_sha = f" books {ns.books_sha}" if split else "" + print(f"report: commit {ns.merged_sha}{books_sha}" + (f" tool {tool}" if tool else ""), flush=True) return rc return 0 @@ -1360,10 +1388,25 @@ def cmd_finish(args) -> int: def cmd_wip(args) -> int: """Wrap-up in one call (lane worktree): commit the given paths on the branch, note the state, status clear.""" start = Path(args.project or Path.cwd()).resolve() - if not config.linked_worktree(start): + wt = config.linked_worktree(start) + if not wt: raise Failure("wip runs inside a lane worktree") top = Path(git_run(start, "rev-parse", "--show-toplevel").stdout.strip() or start) - paths = [str((start / f).resolve()) for f in args.paths] + p = load_project(args) + id = p.resolve(args.id) + btop = (config.git_top(p.cfg.root) or p.cfg.root).resolve() + proot, priv, paths = p.cfg.root.resolve(), [], [] + for f in args.paths: # split project: paths under the private root are committed there + path = (start / f).resolve() + (priv if btop != wt[2].resolve() and (path == proot or proot in path.parents) else paths).append(str(path)) + if priv: + r = git_run(btop, "add", "--", *priv) + if r.returncode: + raise Failure(f"git add failed: {(r.stderr.strip() or 'git error').splitlines()[-1]}") + r = git_run(btop, "commit", "-q", "-m", f"{id} wip: {args.m}", "--", *priv) + if r.returncode: + raise Failure(f"commit failed: {(r.stderr.strip() or r.stdout.strip() or 'git error').splitlines()[-1]}") + print("committed " + " ".join(os.path.relpath(c, btop) for c in priv), flush=True) if paths: r = git_run(top, "add", "--", *paths) if r.returncode: @@ -1372,8 +1415,6 @@ def cmd_wip(args) -> int: if r.returncode: raise Failure(f"commit failed: {(r.stderr.strip() or r.stdout.strip() or 'git error').splitlines()[-1]}") print("committed " + " ".join(os.path.relpath(c, top) for c in paths), flush=True) - p = load_project(args) - id = p.resolve(args.id) with project_lock(p.cfg.root): cmd_note(argparse.Namespace(project=args.project, id=id, line=args.m, dry_run=False)) cmd_status(argparse.Namespace(project=args.project, id=id, kind="clear", value=[], dry_run=False, |
