From e7f642b917f2b7eedb04ccae0a7f15e007326b6b Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sun, 21 Jun 2026 22:43:13 -0700 Subject: [PATCH] feat(worktree): symlink the repo's node_modules into the coder worktree (v0.24.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh `git worktree` is a bare checkout with NO node_modules, so an npm/pnpm pre-PR gate (local_gate_cmd) — or the coder running a build — couldn't resolve deps, and the gate failed in the worktree even though it passes in the main repo. (Surfaced dispatching a DS fix to a pnpm monorepo: `pnpm -C packages/ui build` had no deps in the worktree.) create_worktree now symlinks every node_modules dir from the main repo into the worktree at the same relative path (root + each monorepo workspace package), so the worktree shares the repo's installed deps without a slow/offline per-worktree install. Best-effort: a non-node repo is a no-op; symlink failures are skipped. Build output (dist/) still lands in the worktree — only the deps are shared. Worktree removal unlinks the symlink, never the target (safe). +3 tests (root + monorepo package symlinks; non-node no-op; no descent into node_modules). 196 pass; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- tests/test_worktree.py | 36 ++++++++++++++++++++++++++++++++++++ worktree.py | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 257c742..bdc33c0 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.23.0 +version: 0.24.0 description: >- A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready → in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an diff --git a/pyproject.toml b/pyproject.toml index c5f7ca9..05f78fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.23.0" +version = "0.24.0" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/tests/test_worktree.py b/tests/test_worktree.py index c7b88dc..f1d5193 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -378,3 +378,39 @@ async def teardown(self, scoped): out = await worktree.dispatch_coder(_Coder(), "/wt", "do it", timeout=5) assert out == "built it" assert order == ["forget", "dispatch", "teardown"] + + +# ── link_node_modules: share the main repo's deps with the worktree ──────────────── + + +def test_link_node_modules_symlinks_root_and_monorepo_packages(tmp_path): + repo = tmp_path / "repo" + (repo / "node_modules" / "react").mkdir(parents=True) # root deps + (repo / "packages" / "ui" / "node_modules" / "vite").mkdir(parents=True) # workspace deps + (repo / "src").mkdir() + wt = tmp_path / "wt" + (wt / "packages" / "ui").mkdir(parents=True) # the worktree checkout has the source tree + n = worktree.link_node_modules(str(repo), str(wt)) + assert n == 2 + assert (wt / "node_modules").is_symlink() + assert (wt / "node_modules" / "react").is_dir() # resolves through the symlink + assert (wt / "packages" / "ui" / "node_modules").is_symlink() + assert (wt / "packages" / "ui" / "node_modules" / "vite").is_dir() + + +def test_link_node_modules_noop_for_a_non_node_repo(tmp_path): + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) # no node_modules + wt = tmp_path / "wt" + wt.mkdir() + assert worktree.link_node_modules(str(repo), str(wt)) == 0 + assert not (wt / "node_modules").exists() + + +def test_link_node_modules_does_not_descend_into_node_modules(tmp_path): + """A nested node_modules//node_modules must NOT be separately linked (pruned).""" + repo = tmp_path / "repo" + (repo / "node_modules" / "a" / "node_modules" / "b").mkdir(parents=True) + wt = tmp_path / "wt" + wt.mkdir() + assert worktree.link_node_modules(str(repo), str(wt)) == 1 # only the top-level one diff --git a/worktree.py b/worktree.py index a827b32..63c7a15 100644 --- a/worktree.py +++ b/worktree.py @@ -104,7 +104,41 @@ async def create_worktree(repo: str, base: str, fid: str, root: str = ".worktree rc, _out, err = await _git(repo, "worktree", "add", rel, "-b", branch, start) if rc != 0: raise WorktreeError(f"worktree add failed: {err.strip()[:300]}") - return os.path.abspath(path), branch + abspath = os.path.abspath(path) + # A fresh worktree is a bare checkout with NO node_modules, so an npm/pnpm pre-PR gate + # (or the coder running the build) can't resolve deps. Symlink the main repo's + # node_modules in (best-effort, no-op for non-node repos) rather than a slow/offline + # per-worktree install. + await asyncio.to_thread(link_node_modules, repo, abspath) + return abspath, branch + + +def link_node_modules(repo: str, worktree: str) -> int: + """Symlink every ``node_modules`` dir in the main repo into the worktree at the same + relative path (handles monorepos — root + each workspace package). The worktree shares + the repo's installed deps, so npm/pnpm gates + builds resolve without a per-worktree + install. Best-effort: a non-node repo (no node_modules) is a no-op; symlink failures are + skipped. Build output (dist/, etc.) still lands in the worktree — only the deps are + shared. Returns the number linked.""" + linked = 0 + try: + for root, dirs, _files in os.walk(repo): + if "node_modules" in dirs: + rel = os.path.relpath(os.path.join(root, "node_modules"), repo) + src = os.path.join(repo, rel) + dst = os.path.join(worktree, rel) + try: + if not os.path.lexists(dst): + os.makedirs(os.path.dirname(dst), exist_ok=True) + os.symlink(src, dst) + linked += 1 + except OSError: + pass + # Don't descend into node_modules / git internals / sibling worktrees. + dirs[:] = [d for d in dirs if d not in ("node_modules", ".git", ".worktrees")] + except OSError: + pass + return linked async def remove_worktree(repo: str, worktree: str, branch: str = "") -> None: