The v2.1.0 release of Yaw got through every hard part. It tagged the commit, built every platform, and published the GitHub Release. Then the last step, the website deploy, ran its test suite and died:

'tsc' is not recognized as an internal or external command, operable program or batch file. [fail] Release failed at line 1911 (exit code 1)

TypeScript was still in package.json. It was gone from node_modules. About 20 hours earlier, a cleanup of two stale git worktrees had deleted through a directory junction into the website repo's node_modules, and nothing had run tsc since. This post covers how that happened, the Git for Windows release that had already fixed it (we were one version behind), which other Windows delete commands follow a junction (we probed them), and what we changed so the next release checks its toolchain before anything goes public.

What Happened

Our Claude Code sessions often work in scratch git worktrees so parallel tasks don't trip over each other's files. A new worktree has no node_modules, and the quick way to give it one on Windows is a directory junction to the main checkout's: mklink /J wt\node_modules repo\node_modules. No second install, and unlike a symbolic link, a junction needs no admin rights or Developer Mode to create. Two scratch worktrees of our website repo had been set up that way.

The last output before the failure came from scripts/build-feed.mjs, which made it look like the culprit. It wasn't; it just printed last. One npm ci rebuilt the tree, the deploy went out, and yaw.sh serves 2.1.0.

Why Git Deleted Through a Junction

Windows has two kinds of directory link. A symbolic link (mklink /D) carries the reparse tag IO_REPARSE_TAG_SYMLINK (0xA000000C). A junction (mklink /J) carries IO_REPARSE_TAG_MOUNT_POINT (0xA0000003). Git for Windows treats only the first kind as a link. Its git clean has refused to descend into a junction since Git for Windows 2.23 in August 2019 (the fix was written in December 2018), but the recursive delete behind git worktree remove had no such check, so to it a junction was an ordinary directory: it went inside and deleted what was there, which is the target's contents. On macOS and Linux, git removes a symlink and leaves its target alone.

Then the part that stung. Git for Windows 2.54.0, released April 20, 2026, closed exactly this gap. Its release notes: "Similar to how git clean already avoids traversing NTFS junctions, git worktree remove now does the same." The machine that ran the cleanup was on 2.53.0. We confirmed both sides on a fixture: with 2.53.0, git worktree remove emptied the junction's target; with 2.55.0 it left the target alone, unregistered the worktree, and left the worktree directory on disk holding the untouched junction, exit 0. Update Git for Windows to 2.54 or later and this incident cannot repeat. The rest of this post is for the machines that haven't, and for the other tools, which never got a fix.

We didn't want to guess about those, so we built fixtures (a junction whose target held a sentinel file) and ran every delete we could think of against them. Tested with Git 2.53.0.windows.1, Windows PowerShell 5.1, the MSYS coreutils that ship with Git Bash, GNU find 4.10 and cmd.exe; on Git 2.54 or later only the first row changes:

CommandOn a tree that contains a junctionOn the junction itself
git worktree remove (with or without --force)Empties the target (before 2.54)n/a
rm -rfRemoves the linkRemoves the link; empties the target given <j>/
find ... -deleteRemoves the linkRemoves the link; empties the target given <j>/ or with -H
rmdir /s /q (cmd)Removes the linkRemoves the link; empties the target given <j>\
del /s (cmd)Leaves the link and its target alone (deletes files only)Empties the target
git clean -fdxRemoves the linkn/a
Remove-Item -RecurseRemoves the linkRemoves the link, with or without a trailing \
Get-ChildItem <j> | Remove-Item -Recursen/aEmpties the target

Three things stand out. The case every cleanup script hits, a tree that merely contains a junction, is handled by every tool except old git. The trailing separator matters: rm -rf node_modules removes a junction, while rm -rf node_modules/ empties what it points to, and so does rm -rf */ when one of the matches is a junction. And any path that runs through the link, like rm -rf wt/node_modules/some-package, is just the target's path under another name, so every tool deletes the shared files.

One more catch with the trailing-separator cases. rm -r and find empty the target first, then fail on the link itself with Not a directory, exit 1, and leave the junction standing. Add -f and rm hides even that: rm -rf node_modules/ empties the target, prints nothing and exits 0. Either way the error, when there is one, arrives after the damage, not instead of it.

A directory symlink behaves the way you'd hope: git worktree remove on a tree holding one unlinks it and leaves the target alone, on 2.53 too. PowerShell 7 wasn't installed on the test machine, so we can't say how its Remove-Item behaves.

How to Remove a Worktree That Has a Junction

First, git --version. On 2.54 or later, git worktree remove leaves the junction and its target alone, but it doesn't unlink the junction either: it deletes everything else in the worktree, leaves the directory on disk with the junction still inside, and exits 0 without saying so. Unlink the junction and remove the directory afterwards. On anything older, or when the cleanup runs through another tool, unlink first, then remove, and use an unlink that cannot recurse:

# PowerShell. LinkType prints "Junction" (Target is the shared path), # or nothing for a real directory. $link = Get-Item .\wt\node_modules -Force $link.LinkType, $link.Target # Remove the link only. $false = not recursive, so the target is never touched. [System.IO.Directory]::Delete($link.FullName, $false) # Now there is nothing for git to walk into. git worktree remove wt

From Git Bash, cmd //c rmdir 'wt\node_modules' does the same job. Without /s, rmdir removes a junction but refuses a non-empty real directory, so it fails safe. fsutil reparsepoint query 'wt\node_modules' shows the reparse tag without admin rights if you want to check first (keep the quotes in Git Bash, or it eats the backslash). Then count the shared target before and after. A cleanup can report success while a sibling's toolchain is gone.

Or skip the junction. Give each worktree its own install, which is slower but safe to lose, or use a directory symlink if you have Developer Mode on.

What We Changed

1. The release checks its toolchain before anything goes public

The website repo gained scripts/ensure-deps.mjs. It compares node_modules with package-lock.json directly: every locked package at its locked version, the .bin shim of every direct dependency whose target file exists, and npm's hidden lockfile. When they disagree it runs npm ci, which deletes node_modules and installs exactly the lockfile, then checks again, and fails only if the repair didn't take. If the lockfile itself no longer matches package.json, which npm ci refuses, it regenerates it with npm install first, but only where git doesn't track the lockfile.

It runs in two places. deploy.sh runs it before npm test, and release.sh runs it in preflight against both the website and the app repo, before anything is tagged. Against the gutted tree, it would have seen the missing shims and rebuilt node_modules at the start of the release instead of failing at the end.

Why not just run npm ls? We checked. With node_modules/.bin moved out of the way, npm ls still exits 0 and lists typescript@5.9.3, because the package itself is intact. It checks packages, not the shims your scripts actually run, and a missing shim is exactly what this failure was.

2. A rule for the agents

Yaw Mode layers rules into the Claude Code sessions Yaw starts. Its Windows toolchain rule now carries the probe results and the unlink-first procedure above, and loads when a prompt mentions a junction, mklink or git worktree. The rule bundle ships with each app version, so it reaches Yaw users with the next release. That matters because our first version of the rule blamed rm -rf on the junction, which the probes showed only unlinks it, and that version went out in the v2.1.0 bundle before we probed. An agent follows whatever its context files say, so the corrected rule goes out with the next release.

3. A hook that refuses the delete

Rules are advice. For the destructive case we also wanted a hard stop, so we wrote a Claude Code PreToolUse hook for our own setup (it isn't part of Yaw). It reads every Bash and PowerShell command before it runs and denies a delete that would reach into a junction:

Three things we learned building it:

It's a guard rail, not a sandbox. A delete whose target sits in a variable still gets through (except for git worktree remove, which denies any operand it can't resolve), and so does one run through a git alias or from a script file.

Lessons

Published by Yaw Labs.

Related Articles