aboutsummaryrefslogtreecommitdiffziptar.gz
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 09:24:14 +0200
committergodosa <godosa@godosa.eu>2026-10-07 09:24:14 +0200
commitecf39ec27307f36d9458a22a332c8848bffe79a5 (patch)
tree46a7fec1f1312c286591946b1355a45dd9404cf4
parentdf72f6f16a7a5141ff4df1bab7e1de34207a4163 (diff)
downloadworkflow-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.md1
-rw-r--r--tests/test_split.py86
-rwxr-xr-xwf.py75
3 files changed, 145 insertions, 17 deletions
diff --git a/CHANGES.md b/CHANGES.md
index 9b0521a..418eece 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -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)
diff --git a/wf.py b/wf.py
index 444cd37..f3b7f28 100755
--- a/wf.py
+++ b/wf.py
@@ -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,