TS2724 "has no exported member" in a git worktree: a symlinked node_modules typechecks the main checkout's source

·4 mins

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/* point exports straight 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.