git worktree plus a junctioned node_modules: how we broke our shared dependencies, and how we stop it now
Table of Contents
On Windows, linking a git worktree's node_modules back to the main checkout with mklink /J saves installing packages once per worktree. The price: when you clean up, git worktree remove walks through the junction and empties node_modules in the main checkout. We hit this four times between early September and mid October. Retesting in a sandbox this week turned up two more things: leaving out --force does not help, and running npm ci inside the junction empties the main copy too. Our rule now is no junctions in worktrees, each one runs its own npm ci. Where a junction already exists, unlink it, confirm the main copy is still there, and only then remove the worktree. A check script is at the end.
Why use a junction at all?
We run many Claude Code workers at once, each changing code and running builds in its own git worktree (our rules for parallel workers are in this post). Every worktree needs node_modules to run tsc or a build.
The shortcut is to link back to the main checkout:
mklink /J C:\work\wt-feature\frontend\node_modules C:\work\repo\frontend\node_modules
The worktree runs immediately, uses no extra space and skips the install. At first we wrote this into worker instructions ourselves, and later it ended up in one work line's notes as well. The trouble comes at cleanup.
Four incidents
First, September 3. Three workers each moved code in their own worktree, all with frontend/node_modules linked to the main checkout of the same product repo. After merging, we cleaned up with git worktree remove --force. Git's recursive delete walked into the junction and emptied both the frontend and engine node_modules in the main checkout. Tests there immediately failed with MODULE_NOT_FOUND, and the only fix was npm ci again.
Second, September 4. This time we knew to unlink first, and wrote cmd /c rmdir "C:\..." 2>/dev/null in Git Bash. Git Bash converts arguments like /c as if they were paths, so cmd never received /c, opened in interactive mode, printed its version banner and exited. rmdir never ran. Errors were discarded, so everything looked fine. Then we removed the worktree, and the main copy was emptied again.
Third and fourth, October 10 at 00:31 and October 11 at 04:02. The work line that maintains our website linked frontend/node_modules in a temporary worktree to another worktree's node_modules, which was itself a junction to the main checkout. Two layers. Cleanup with remove --force went through both, emptied the main copy, and the next day four nightly scheduled jobs all exited 1. We found the root cause by reading the session transcripts: that work line's notes told it to use mklink /J.
Retesting this week: which commands follow the junction?
We made a throwaway repo in a temp folder, put two entries in the main checkout's node_modules, linked the worktree's node_modules to it with mklink /J, and rebuilt the setup fresh for each delete method. Environment: Windows 10, git 2.52.0.windows.1, PowerShell 5.1, npm 10.9.4.
| Run in the worktree | Main checkout's node_modules |
|---|---|
git worktree remove (no --force) |
emptied |
git worktree remove --force |
emptied |
Git Bash rm -rf <worktree> |
intact |
PowerShell 5.1 Remove-Item -Recurse -Force <worktree> |
intact |
cmd rmdir /s /q <worktree> |
intact |
Unlink first, then git worktree remove |
intact |
npm ci |
emptied; junction became a real folder |
npm install |
intact; junction became a real folder |
Two results did not match what we believed.
First, leaving out --force does not save you. node_modules is in .gitignore, so git treats the worktree as clean, removes it anyway, and follows the junction anyway. Our old note said "unlink first, then remove (without --force)", as if skipping force were part of the safety. It is not. The only thing that helps is unlinking first.
Second, one of our earlier posts said rm -rf also follows junctions. On this machine, Git Bash rm -rf, PowerShell 5.1 Remove-Item and rmdir /s all left the main copy alone, so we are correcting that here, and that post has been fixed too. We only tested this environment and cannot vouch for other versions, so the rule stays: if there is a junction, unlink it first.
npm ci is the easiest one to miss. We pinned is-number@7.0.0 in the main checkout and added a marker file that only existed there; the worktree's lockfile said 6.0.0. After npm ci in the worktree, the main checkout's node_modules was empty, packages and marker file gone, while the worktree's node_modules had become a real folder holding 6.0.0. Plenty of worker instructions say "npm ci && npm run build". In a worktree with a junction, that one line empties the main copy.
What does it look like after it has been emptied?
Hard to spot. In our tests, when git emptied the main copy, the node_modules folder itself was still there, just empty. So a check like "does node_modules exist" passes, and scheduled scripts do not fail at startup; they fail only when they actually require a package. That matches what we saw in incidents three and four: the wipe happened during a late-night cleanup, and the four nightly jobs all exited 1 the next day.
So after cleanup, the thing to check is not whether the folder exists but whether its entry count dropped. The script below counts before and after every unlink.
Our rules now
- No junctions in worktrees; each runs its own
npm ci. For the website worktree used to write this post,npm ciinstalled 1,284 packages in 46 seconds. That time is a fair price for a safe main checkout. - Fixed cleanup order: delete the worktree's own
node_modulesfirst, thengit worktree remove. If removal fails, look atgit statusinstead of reaching for--force. - If a junction already exists, run the check script first, unlink, confirm the entry count on the other side did not change, then remove the worktree.
- Always unlink non-recursively: PowerShell
[IO.Directory]::Delete('path', $false), cmdrmdir(no/s), Nodefs.rmdirSync. From Git Bash, call cmd ascmd //c. - A guard hook: since October 12 we have a Claude Code PreToolUse hook that blocks removing a worktree containing a junction or symlink and prints the safe way to unlink. If the hook itself errors, it lets the command through so it never blocks work. Its limit is that it only reads command text and cannot catch
npm ci, which is why rule 1 is the main defense.
On October 26 we used this order to clean up 112 worktrees in one go. They contained 61 node_modules links, all unlinked before removal; every main checkout stayed intact, and free space on drive C went from 16.83 GB to 40.40 GB. This week, three workers' cleanup reports noted unlinking the junction first and then confirming the main copy was intact; two other workers ran their own npm ci in the worktree and never created a junction. In other words, some workers still fall back on junctions out of habit, which is why the hook and the check script below are still needed.
The check script
Run it from the repo root. With no arguments it lists every junction and symlink inside each worktree, follows chains to the final target, and marks the ones that end in the main checkout. With --unlink <worktree> it removes only that worktree's links, counts the target's entries before and after, and stops if they do not match.
#!/usr/bin/env node
// wt-links.mjs: list junctions / symlinks inside git worktrees before you remove them.
// node wt-links.mjs scan every worktree of the repo in cwd
// node wt-links.mjs --unlink <wt> remove only the links inside <wt>, then check the targets survived
// exit 0 = no links (safe to `git worktree remove`), 1 = links found, 2 = error / target damaged
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const MAX_DEPTH = 4;
const SKIP = new Set(['.git', 'node_modules']); // never walk into a real node_modules, so package-internal links are not listed
function worktrees() {
const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { encoding: 'utf8' });
return out.split('\n').filter(l => l.startsWith('worktree ')).map(l => path.resolve(l.slice(9).trim()));
}
// Walk without ever stepping into a link. On Windows, Node's lstat reports junctions as symlinks.
function findLinks(root) {
const found = [];
const stack = [[root, 0]];
while (stack.length) {
const [dir, depth] = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
const p = path.join(dir, e.name);
let st;
try { st = fs.lstatSync(p); } catch { continue; }
if (st.isSymbolicLink()) {
let target = '?', real = '?';
try { target = path.resolve(dir, fs.readlinkSync(p)); } catch {}
try { real = fs.realpathSync(p); } catch {} // follows chains: link -> link -> real folder
found.push({ link: p, target, real });
} else if (st.isDirectory() && !SKIP.has(e.name) && depth < MAX_DEPTH) {
stack.push([p, depth + 1]);
}
}
}
return found;
}
function count(dir) {
try { return fs.readdirSync(dir).length; } catch { return -1; }
}
const args = process.argv.slice(2);
if (args[0] === '--unlink') {
const wt = path.resolve(args[1] || '');
if (!args[1] || !fs.existsSync(wt)) { console.error('usage: --unlink <worktree path>'); process.exit(2); }
const links = findLinks(wt);
if (!links.length) { console.log(`no links in ${wt}`); process.exit(0); }
let bad = 0;
for (const { link, target } of links) {
const before = count(target);
fs.rmdirSync(link); // not recursive: removes the link itself, never the target's contents
const after = count(target);
const ok = before === after && after >= 0;
if (!ok) bad++;
console.log(`${ok ? 'OK ' : 'BAD '} unlinked ${link} (target ${target}: ${before} -> ${after} entries)`);
}
const left = findLinks(wt);
if (bad || left.length) { console.error(`stop: ${bad} target(s) changed, ${left.length} link(s) left`); process.exit(2); }
console.log(`done. now run: git worktree remove "${wt}"`);
process.exit(0);
}
let total = 0;
const all = worktrees();
const main = all[0];
for (const wt of all) {
const links = findLinks(wt);
total += links.length;
for (const { link, target, real } of links) {
const intoMain = real.toLowerCase().startsWith(main.toLowerCase() + path.sep);
const chain = real !== target ? ` => ${real}` : '';
console.log(`${wt === main ? 'MAIN' : 'WT '} ${link} -> ${target}${chain}${intoMain ? ' [ends in main checkout]' : ''}`);
}
}
console.log(`${all.length} worktree(s), ${total} link(s)`);
process.exit(total ? 1 : 0);
In the sandbox we rebuilt the two-layer junction from incidents three and four. The scan looks like this:
WT C:\tmp\jx-lab\T\wt1\frontend\node_modules -> C:\tmp\jx-lab\T\main\frontend\node_modules [ends in main checkout]
WT C:\tmp\jx-lab\T\wt2\frontend\node_modules -> C:\tmp\jx-lab\T\wt1\frontend\node_modules => C:\tmp\jx-lab\T\main\frontend\node_modules [ends in main checkout]
3 worktree(s), 2 link(s)
Running --unlink on each worktree showed the target at 2 → 2 both times. A rescan found 0 links, then git worktree remove on both worktrees left the main node_modules with its 2 entries.
The default scan depth is 4 levels, which covers paths like frontend/node_modules; raise MAX_DEPTH if your repo nests packages deeper. Real node_modules folders are not walked into, so links inside packages (npm workspaces, for example) are not listed. We have only tested this on Windows junctions, not on Linux or macOS symlinks. The script only unlinks. It never runs git worktree remove for you; that step is left for a person who has read the output.
If you want to follow along
- Run
node wt-links.mjsin your main checkout and see how many worktrees currently hang on junctions. - From now on, run
npm ciin new worktrees instead of creating junctions. - To clean up old ones, run
--unlinkone worktree at a time, and onlygit worktree removeafter you see OK. - If you also use AI workers, put "unlink before removing" in the last section of every task instruction, not only in a notes file. In our third and fourth incidents, the notes themselves were what taught the wrong thing.
FAQ
If I run git worktree remove without --force, is the junction target safe?
No. We tested on git 2.52.0.windows.1. Because node_modules is in .gitignore, git treats the worktree as clean and removes it without --force, and it still follows the junction and empties node_modules in the main checkout. --force only matters when there are uncommitted changes; it has nothing to do with junctions.
What happens if I run npm ci in a worktree whose node_modules is a junction?
The main checkout's node_modules gets emptied. In our test with npm 10.9.4, after npm ci the packages and a marker file we had placed in the main copy were all gone, and the worktree's node_modules had turned from a junction into a real folder holding the versions from the worktree's own lockfile. In the same setup, npm install left the main copy intact but also replaced the junction with a real folder.
How do I delete only the junction, not the files it points to?
Use a non-recursive delete. In PowerShell, [IO.Directory]::Delete('path', $false), where false means not recursive; in cmd, rmdir without /s; in Node, fs.rmdirSync. From Git Bash, call cmd as cmd //c. Written as cmd /c, Git Bash converts the argument like a path, cmd opens in interactive mode and rmdir never runs. After deleting, count the files on the other side before removing the worktree.