aboutsummaryrefslogtreecommitdiffziptar.gz
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 09:12:20 +0200
committergodosa <godosa@godosa.eu>2026-10-07 09:12:20 +0200
commit0f6857c26e5b2777c6be927dbc75fdcdbfe0e750 (patch)
tree14639121dce67e928a80c23914f783e5c4e8eec8
parentec091e7df0ef0804d12cc49d09b2f88c19cc6534 (diff)
downloadworkflow-0f6857c26e5b2777c6be927dbc75fdcdbfe0e750.tar.gz
workflow-0f6857c26e5b2777c6be927dbc75fdcdbfe0e750.zip
split projects 1/6: Config.split, push key, .wf-home discovery
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
-rw-r--r--CHANGES.md1
-rw-r--r--templates/workflow.toml1
-rw-r--r--tests/test_split.py74
-rw-r--r--wflib/config.py24
4 files changed, 98 insertions, 2 deletions
diff --git a/CHANGES.md b/CHANGES.md
index f5d93c1..f909bc7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,5 @@
# Changes (newest first)
+- 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.
- 2026-10-07 Denylist guard at commit time: `.githooks/pre-commit` (staged lines + paths) and `.githooks/commit-msg` run `scripts/denylist_check.py` against the git-ignored `publish-denylist.local` (worktree top + main tree, plus `git config denylist.file` lists; `word` / `!token` exemption / `#`, case-insensitive; output `word #<line>` only; no list → warning). Enable per clone: `git config core.hooksPath .githooks`; audit: `denylist_check.py tree`. `scripts/publish_snapshot.py` removed (history is public now). Projects: nothing.
- 2026-10-07 Tests use generic fixtures (/h/u, 10.0.0.x) instead of real home-dir and LAN paths (publish-scan clean). Projects: nothing.
diff --git a/templates/workflow.toml b/templates/workflow.toml
index b8b05cd..08393ab 100644
--- a/templates/workflow.toml
+++ b/templates/workflow.toml
@@ -34,3 +34,4 @@ done = [] # extra checklist lines `wf done` prints
# cloud = true # cloud lane opt-in (wf cloud send)
# cloud_include = ["out/pack"] # git-ignored inputs force-added to the cloud snapshot
# cloud_note = "Game data: out/pack" # appended to the cloud prompt
+# push = ["tools/publish-push ../code"] # split project: run in root after a merge (default: git push home)
diff --git a/tests/test_split.py b/tests/test_split.py
new file mode 100644
index 0000000..2026e33
--- /dev/null
+++ b/tests/test_split.py
@@ -0,0 +1,74 @@
+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"])
diff --git a/wflib/config.py b/wflib/config.py
index 8f75c89..5339313 100644
--- a/wflib/config.py
+++ b/wflib/config.py
@@ -8,10 +8,11 @@ from pathlib import Path
from . import lanes as lanes_mod
NAME = "workflow.toml"
+WF_HOME = ".wf-home"
FORMAT = 1
KEYS = {"format", "tasks", "archive", "docs", "verify", "done", "ledgers", "anchors", "ctx_hint", "worktree_setup", "quick_gate",
"slice_above", "lanes", "areas", "code_root", "area_stale_commits", "area_ignore",
- "cloud", "cloud_include", "cloud_note"}
+ "cloud", "cloud_include", "cloud_note", "push"}
AREA_IGNORE = ("tests", "test", "docs", "doc")
ANCHOR_KEYS = {"index", "index_section", "specs"}
@@ -46,12 +47,18 @@ class Config:
cloud: bool = False # cloud lane opt-in (wf cloud send)
cloud_include: list[str] = field(default_factory=list) # git-ignored paths force-added to the snapshot
cloud_note: str | None = None # appended to the cloud prompt
+ push: list[str] = field(default_factory=list) # wf finish/merge/push: run in root after a merge (else push home)
@property
def areas_file(self) -> Path:
return self.areas or (self.local or self.root) / "CLAUDE.md"
@property
+ def split(self) -> bool:
+ """code_root is another git repo than root: private home repo + code repo (split project)."""
+ return bool(self.code_root) and git_top(self.code_root) not in (None, git_top(self.root))
+
+ @property
def code(self) -> Path:
return self.code_root or self.root
@@ -104,8 +111,15 @@ def linked_worktree(folder: Path) -> tuple[Path, Path, Path] | None:
def find_root(start: Path) -> Path:
"""Folder with workflow.toml at or above start; in a linked git worktree the main tree's
- same folder, when it is a project too (one TASKS.md for all worktrees)."""
+ same folder, when it is a project too (one TASKS.md for all worktrees). A code worktree of a
+ split project: its .wf-home."""
start = start.resolve()
+ top = git_top(start)
+ if top and (top / WF_HOME).is_file():
+ home = Path((top / WF_HOME).read_text().strip())
+ if not (home / NAME).is_file():
+ raise ConfigError(f"{WF_HOME} points to {home}: no {NAME} there (fix or delete {top / WF_HOME})")
+ return home
for folder in (start, *start.parents):
if (folder / NAME).is_file():
wt = linked_worktree(folder)
@@ -117,6 +131,11 @@ def find_root(start: Path) -> Path:
raise ConfigError(f"no {NAME} in {start} or above: not a wf project (wf init makes one)")
+def code_main(cfg: "Config") -> Path | None:
+ """Main tree top of the code repo of a split project, else None."""
+ return git_top(cfg.code_root) if cfg.split else None
+
+
def find_local(start: Path) -> Path | None:
"""In a linked worktree whose project root is the main tree's (find_root): the worktree's own copy
of that folder when it has a workflow.toml, else None."""
@@ -222,4 +241,5 @@ def load(root: Path, local: Path | None = None) -> Config:
local=local,
cloud=data.get("cloud", False), cloud_include=_strings(data, "cloud_include"),
cloud_note=_string(data, "cloud_note"),
+ push=_strings(data, "push"),
)