A directory junction, a routine cleanup, and a release that failed 20 hours later with its tag already public.
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.
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.
git worktree remove on those two worktrees. Git walked into the junction and deleted the shared node_modules in directory order: the .bin folder holding every command shim, npm's hidden lockfile (node_modules/.package-lock.json), and two packages outright (@aws-sdk/client-sesv2 and @aws/lambda-invoke-store). It stopped only when it reached a path longer than the 260 characters Git for Windows allows unless core.longpaths is on: error: failed to delete '...': Filename too long. The session finished the cleanup another way and reported it clean.tsc from that tree, so nothing noticed.npm test starts with a typecheck, and the tsc shim had gone with the rest of .bin.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.
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:
| Command | On a tree that contains a junction | On the junction itself |
|---|---|---|
git worktree remove (with or without --force) | Empties the target (before 2.54) | n/a |
rm -rf | Removes the link | Removes the link; empties the target given <j>/ |
find ... -delete | Removes the link | Removes the link; empties the target given <j>/ or with -H |
rmdir /s /q (cmd) | Removes the link | Removes 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 -fdx | Removes the link | n/a |
Remove-Item -Recurse | Removes the link | Removes the link, with or without a trailing \ |
Get-ChildItem <j> | Remove-Item -Recurse | n/a | Empties 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.
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.
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.
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.
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:
git worktree remove <x>: resolve <x> the way git does (a unique suffix among git worktree list first, then the real path) and scan that tree for junctions. If it can't resolve the operand, it denies, because this is the command proven to do the damage.del /s on any link; Get-ChildItem <link> | Remove-Item -Recurse.cmd /c, bash -c, powershell -EncodedCommand, Invoke-Expression and eval, and past prefixes like sudo, env, timeout, xargs and npx. Globs are expanded by bash itself, so rm -rf */ is checked against the same matches rm would get.Three things we learned building it:
rd /s/q with the switches run together, find -H, a worktree named by a suffix of its path, and a delete handed through xargs or npx rimraf. The test suite is 81 cases against real junctions, a directory symlink and a worktree that resolves by suffix.settings.json made at launch, so when we swapped the bash script for the Node one and deleted the old file, sessions that were already running kept calling the old path. A hook command that fails that way is a non-blocking error, so in four live sessions the gate silently stopped gating. The old path is now a shim that execs the new hook.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.
build-feed.mjs was innocent.node_modules through a link.Published by Yaw Labs.