Skip to content

Commit c4a2133

Browse files
mabry1985claude
andauthored
feat(worktree): symlink the repo's node_modules into the coder worktree (v0.24.0) (#58)
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) <noreply@anthropic.com>
1 parent 6ef640d commit c4a2133

4 files changed

Lines changed: 73 additions & 3 deletions

File tree

protoagent.plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: project_board
22
name: Project Board (coding orchestration)
3-
version: 0.23.0
3+
version: 0.24.0
44
description: >-
55
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
66
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "project-board"
3-
version = "0.23.0"
3+
version = "0.24.0"
44
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
55
requires-python = ">=3.11"
66

tests/test_worktree.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,39 @@ async def teardown(self, scoped):
378378
out = await worktree.dispatch_coder(_Coder(), "/wt", "do it", timeout=5)
379379
assert out == "built it"
380380
assert order == ["forget", "dispatch", "teardown"]
381+
382+
383+
# ── link_node_modules: share the main repo's deps with the worktree ────────────────
384+
385+
386+
def test_link_node_modules_symlinks_root_and_monorepo_packages(tmp_path):
387+
repo = tmp_path / "repo"
388+
(repo / "node_modules" / "react").mkdir(parents=True) # root deps
389+
(repo / "packages" / "ui" / "node_modules" / "vite").mkdir(parents=True) # workspace deps
390+
(repo / "src").mkdir()
391+
wt = tmp_path / "wt"
392+
(wt / "packages" / "ui").mkdir(parents=True) # the worktree checkout has the source tree
393+
n = worktree.link_node_modules(str(repo), str(wt))
394+
assert n == 2
395+
assert (wt / "node_modules").is_symlink()
396+
assert (wt / "node_modules" / "react").is_dir() # resolves through the symlink
397+
assert (wt / "packages" / "ui" / "node_modules").is_symlink()
398+
assert (wt / "packages" / "ui" / "node_modules" / "vite").is_dir()
399+
400+
401+
def test_link_node_modules_noop_for_a_non_node_repo(tmp_path):
402+
repo = tmp_path / "repo"
403+
(repo / "src").mkdir(parents=True) # no node_modules
404+
wt = tmp_path / "wt"
405+
wt.mkdir()
406+
assert worktree.link_node_modules(str(repo), str(wt)) == 0
407+
assert not (wt / "node_modules").exists()
408+
409+
410+
def test_link_node_modules_does_not_descend_into_node_modules(tmp_path):
411+
"""A nested node_modules/<pkg>/node_modules must NOT be separately linked (pruned)."""
412+
repo = tmp_path / "repo"
413+
(repo / "node_modules" / "a" / "node_modules" / "b").mkdir(parents=True)
414+
wt = tmp_path / "wt"
415+
wt.mkdir()
416+
assert worktree.link_node_modules(str(repo), str(wt)) == 1 # only the top-level one

worktree.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,41 @@ async def create_worktree(repo: str, base: str, fid: str, root: str = ".worktree
104104
rc, _out, err = await _git(repo, "worktree", "add", rel, "-b", branch, start)
105105
if rc != 0:
106106
raise WorktreeError(f"worktree add failed: {err.strip()[:300]}")
107-
return os.path.abspath(path), branch
107+
abspath = os.path.abspath(path)
108+
# A fresh worktree is a bare checkout with NO node_modules, so an npm/pnpm pre-PR gate
109+
# (or the coder running the build) can't resolve deps. Symlink the main repo's
110+
# node_modules in (best-effort, no-op for non-node repos) rather than a slow/offline
111+
# per-worktree install.
112+
await asyncio.to_thread(link_node_modules, repo, abspath)
113+
return abspath, branch
114+
115+
116+
def link_node_modules(repo: str, worktree: str) -> int:
117+
"""Symlink every ``node_modules`` dir in the main repo into the worktree at the same
118+
relative path (handles monorepos — root + each workspace package). The worktree shares
119+
the repo's installed deps, so npm/pnpm gates + builds resolve without a per-worktree
120+
install. Best-effort: a non-node repo (no node_modules) is a no-op; symlink failures are
121+
skipped. Build output (dist/, etc.) still lands in the worktree — only the deps are
122+
shared. Returns the number linked."""
123+
linked = 0
124+
try:
125+
for root, dirs, _files in os.walk(repo):
126+
if "node_modules" in dirs:
127+
rel = os.path.relpath(os.path.join(root, "node_modules"), repo)
128+
src = os.path.join(repo, rel)
129+
dst = os.path.join(worktree, rel)
130+
try:
131+
if not os.path.lexists(dst):
132+
os.makedirs(os.path.dirname(dst), exist_ok=True)
133+
os.symlink(src, dst)
134+
linked += 1
135+
except OSError:
136+
pass
137+
# Don't descend into node_modules / git internals / sibling worktrees.
138+
dirs[:] = [d for d in dirs if d not in ("node_modules", ".git", ".worktrees")]
139+
except OSError:
140+
pass
141+
return linked
108142

109143

110144
async def remove_worktree(repo: str, worktree: str, branch: str = "") -> None:

0 commit comments

Comments
 (0)