---
title: "You can't repoint a junction under live file handles (why I had to stage the .claude move)"
canonical: https://dxdev.com/blog/cant-repoint-a-junction-under-live-file-handles/
datePublished: 2026-05-21
---
My C: drive dropped to 230 MB free out of 231 GB, and one of the fattest things I could move off it was a 1.7 GB `.claude` directory full of transcripts and a chrome-debug profile. The obvious move was to relocate it to G: and leave a directory junction behind so every app still saw a normal folder at the old path. The obvious move was also wrong, because at that moment a stack of Claude Code sessions were running in parallel and every one of them was appending to its own transcript file under that directory. You cannot repoint a junction under live file handles. Here is why, and what I did instead.

## The plan that doesn't work

The idea is clean on paper. Move the data once, leave a pointer behind:

```
robocopy C:\Users\<you>\.claude G:\Caches\.claude /E
rmdir C:\Users\<you>\.claude
mklink /J C:\Users\<you>\.claude G:\Caches\.claude
```

`mklink /J` makes a directory junction, which is a reparse point the filesystem resolves transparently. No admin token needed, no app changes, no symlink-permission dance. Apps open `C:\Users\<you>\.claude\projects\foo.jsonl` and the OS quietly redirects to `G:\Caches\.claude\projects\foo.jsonl`. For a cold directory this is a quick operation and it just works.

The problem is the word "cold." My `.claude` directory was anything but. Around eight sessions were live in parallel, and a Claude Code session keeps a steady grip on the files under there: the per-project `projects/<slug>/<session-id>.jsonl` transcript it appends to on every turn, plus lock files and shell snapshots. Those are open handles, not idle files. And open handles are exactly what breaks the plan above.

## Why each step fails on a live directory

Two distinct mechanisms bite you, and it's worth separating them because they fail differently on Windows and Linux.

**First, the rename/delete itself is refused.** On Windows, you cannot rename or `rmdir` a directory that has open handles on files inside it. The `rmdir` fails with the sharing-violation error, "The process cannot access the file because it is being used by another process," and the junction never gets created because the old directory is still sitting there. Robocopy can copy a file that's open for append (it reads what it can), but the teardown step that's supposed to clear the path for the junction is the part that gets stonewalled. As long as one session holds `projects/.../session.jsonl` open, the directory is pinned.

**Second, even if you somehow swapped the path, the running processes wouldn't follow it.** This is the part people miss. A junction (or a symlink) is resolved at `open()` time, not on every read or write. When a process called `open()` on the transcript file, the OS walked the path, resolved any reparse points then, and handed back a handle that points at the underlying file object. After that, the handle is bound to that object. It does not re-walk the path. Swap the junction underneath a running process and its already-open handles keep writing to wherever they were originally resolved.

On Linux the same truth shows up through the inode. An open file descriptor refers to an inode, not a path. If you move the directory and repoint a symlink, every FD a live process holds keeps pointing at the old inode. The process happily keeps writing to the old location, now orphaned from the path you think it lives at. New `open()` calls go to the new place, old descriptors go to the old place, and you've split your live data across two directories without a single error to tell you.

So the failure mode if you force it is the worst kind: not a crash, but silent divergence. Half your sessions writing to G:, half still appending to the now-detached C: data through handles they opened before the swap. For append-only JSONL transcripts that's straight-up corruption of the record, and there's no exception thrown to flag it.

## The one rule that explains all of it

A symlink or junction swap is only honored by future `open()` calls.

That's the whole lesson compressed to a sentence. Anything that already has the old path open keeps using the old resolution, because the resolution happened once, at open time, and was baked into the handle. You cannot hot-migrate a directory that live processes have files open in. There is no "and the running apps pick up the new location" step, because that step does not exist at the OS level.

Which means the migration is not a clever-trick problem. It's a sequencing problem. The swap has to happen when nothing has those files open, so that the next `open()` (the one the freshly-restarted session makes) resolves through the new junction to the new drive. Cold is not a nice-to-have here. Cold is the entire mechanism.

## Stage it, guard it, run it cold

Since the operation is only safe with zero live sessions, the worst thing I could do is run it by hand in a terminal while eight other windows are mid-turn. The cleanup session that hit the disk wall is not the session that should also fire the move. So I staged it as a script that refuses to run unless the world is actually cold.

The script, `relocate_claude_to_g.ps1`, aborts on three guards before it touches anything:

- A live `claude` process exists. If any session is running, stop. This is the guard that actually matters, because it's the open handles that make the move unsafe.
- The destination already exists. Don't clobber a prior partial move.
- The junction is already in place. If `.claude` is already a reparse point, the move was already done; bail.

Only past all three does it copy with `robocopy` (`/E`, plus a file-count verification before it touches the original), rename the old directory to `.claude.bak` instead of deleting it outright, and create the junction. If the `mklink` step fails it rolls the `.bak` back into place. The original only gets removed by hand once the relocated copy is confirmed working. The point of the guards isn't politeness. The script encodes the OS rule directly: it will not perform the swap while a handle could be open, so it can't produce the silent-divergence outcome even if I run it at the wrong moment. The right time to run it is from a clean terminal with every Claude window closed, and the guard makes "is it actually clean" a check instead of a hope.

The companion fix went after the cause rather than the symptom. The disk kept filling because npm's cache and the Temp directory live on C: and grow without bound, so a second script, `redirect_caches_to_g.ps1`, redirects npm cache and Temp to `G:\Caches`. That one's a config change, not a live-handle problem, so it doesn't need the same guarding. But it's the part that stops C: from refilling a week later and forcing the whole exercise again.

## The takeaway

If you ever want to move a busy directory behind a symlink or junction, internalize the one rule and let it dictate your sequencing: the swap is honored only by future `open()` calls, so any process holding the old path open keeps using the old location. On Windows the OS protects you a little by refusing to delete a directory with open handles. On Linux it doesn't, and you can quietly split live writes across two inodes with no error at all. Either way the answer is the same. You cannot hot-migrate a directory that live processes have files open in. Stage the move into a script, guard it so it aborts the instant it sees a live process, and run it cold.

## Related

- [Moving Claude Code Off Your C: Drive: The 12.4 GB VM Bundle and the UWP Permission Wall](move-claude-code-off-c-drive-junction-uwp-permission-wall): the fuller story of reclaiming C: drive space occupied by Claude and its dependencies
- [Where does the daemon live? Reusing a scheduled-task tick instead of always-on infra](where-does-the-daemon-live-reuse-a-schtask-tick): deciding where persistent processes should live on the same Windows box
- [My pinned app kept vanishing after every reboot, and it wasn't Windows being flaky](pinned-app-vanishes-after-reboot-self-updater-staging-folder): another Windows path/process lifecycle surprise with a staged fix
- [claude --resume worked in my terminal and failed in my web app: the LocalSystem homedir trap](nssm-localsystem-homedir-claude-resume-systemprofile): how process identity affects which paths a service can reach
- [The console window that flashed every 30 minutes: Interactive vs S4U scheduled tasks](scheduled-task-console-flash-interactive-vs-s4u): Windows process-session isolation producing unexpected runtime behavior
