TS2724 "has no exported member" in a git worktree: a symlinked node_modules typechecks the main checkout's source
A coding agent cherry-picked a subagent’s commit into an integration worktree, ran pnpm typecheck and got:
apps/web/lib/uploads.ts(3,59): error TS2724: '"@app/persistence"' has no exported member named 'recordRawUpload'. Did you mean 'recordUpload'?
recordRawUpload was right there, exported from packages/persistence/src/index.ts in that worktree. If tsc can’t see an export that you can, and the worktree’s node_modules is a symlink to another checkout, tsc is reading the other checkout’s source.
The setup #
- A pnpm 10 monorepo on macOS. The workspace packages
@app/*pointexportsstraight at./src/index.ts, so there is no build step between packages. - An orchestrating agent that creates a git worktree, a branch, and a subagent per issue, and cherry-picks finished work into an integration worktree.
- A full disk.
What went wrong #
Every worktree had its own install, and du counted up to 513 MB of node_modules in each. A dozen worktrees later, the disk was full:
Filesystem Size Used Avail Capacity iused ifree %iused Mounted on
/dev/disk3s5 460Gi 420Gi 116Mi 100% 6.6M 1.2M 85% /System/Volumes/Data
To save space, the agent replaced the node_modules of each new worktree (the root, apps/web and every package) with a symlink to the main checkout’s copy:
ln -s ../project/node_modules ../project-integration/node_modules
ln -s ../../../project/apps/web/node_modules ../project-integration/apps/web/node_modules
The first typecheck after that passed. Later, a change that added an export to @app/persistence and used it in apps/web failed with TS2724. readlink shows why:
$ readlink apps/web/node_modules/@app/persistence
../../../../packages/persistence
pnpm links workspace packages with relative symlinks. A relative link resolves from the directory it really sits in, and that directory is now in the main checkout. So @app/persistence in the worktree was /Users/me/project/packages/persistence, the main checkout’s source, which didn’t have the new function yet.
The typecheck that passed also validated the main checkout.
Two subagents in their own worktrees hit the same error. One deleted the links, reinstalled, and its typecheck passed. The other wrote “Validation blocked by worktree node_modules links targeting older shared package. Source diff reviewed; commit now.” and committed without a passing typecheck.
Fix #
Give each worktree its own install. pnpm keeps package files in a content-addressable store, so an offline install only links them into place:
rm node_modules apps/web/node_modules packages/*/node_modules # the symlinks
pnpm install --offline --frozen-lockfile
This took 3.6 seconds and reported resolved 378, reused 378, downloaded 0. If the store is missing a package, --offline fails; drop the flag.
Then check that every workspace package resolves inside the worktree. Run it with bash: zsh aborts on the first glob that matches nothing (no matches found), and the agent’s own fix hit exactly that.
#!/usr/bin/env bash
# Print workspace packages that resolve outside this checkout.
root=$(pwd -P)
for link in node_modules/@app/* */*/node_modules/@app/*; do
[ -e "$link" ] || continue
target=$(realpath "$link")
case $target in "$root"/*) ;; *) echo "$link -> $target" ;; esac
done
No output means every workspace package comes from this worktree.
If you have to share node_modules, share only the third-party packages. The agent’s fix was an overlay: a real node_modules directory per package, with every entry symlinked to the main checkout except @app, whose entries point at the worktree’s own packages. Copying the main checkout’s @app directory with cp -RP gives the same result, because the copied relative links resolve from their new place.
Why #
TypeScript (with the default preserveSymlinks: false) and Node both resolve a package to its real path. Third-party packages are the same files in every checkout that shares a lockfile, so following the link to the main checkout is harmless. Workspace packages are your source code, and that differs between checkouts by design. Once a branch changes its dependencies, the third-party half goes stale as well.
The disk savings were probably smaller than they looked. pnpm clones or hard-links files from its store, and du counts those blocks again in every tree, so its per-worktree numbers overstate what each install cost. The disk was also full for other reasons, and nobody measured how much the links saved.
Takeaway #
A worktree that borrows another checkout’s node_modules gets that checkout’s workspace packages too, and typecheck and tests then run against the wrong code, passing or failing. Run pnpm install --offline in every new worktree.
If your agents create worktrees, run the check above in one of them today.