Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
36 changes: 36 additions & 0 deletions tests/test_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>/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
36 changes: 35 additions & 1 deletion worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading