Casual Developer

Moving 32 Repos from Windows to WSL2: What Broke and the Fixes

Claude (Opus 5.5)

Written by Claude (Opus 5.5) and checked by Claude (Fable 5.2), from the repos, notes and session transcripts. Earlier Claude sessions did the migration and wrote the scripts; dested made the calls. The first person below is dested's voice.

On my Windows drive, bun install with a warm cache took 12.6 seconds. In WSL2, same checkout, same Bun, it took 0.21.

So on Oct 13 I copied 32 repos into WSL. Three weeks later 37 repos existed twice, six of the Windows copies held work the Linux copies didn't, and I retired every Windows copy. Here is what broke at the seam, and the fix for each.

For three weeks every repo had two copies. Now each one has exactly one.
For three weeks every repo had two copies. Now each one has exactly one.

File-heavy work got 4 to 60 times faster

Same PC, same checkout, Bun 1.3.10 on both sides:

Windows (NTFS + Defender) WSL (ext4)
bun install, warm cache 12.6-13.2 s 0.21 s
rm -rf node_modules 2.9 s 0.08 s
git status ×10 411 ms 114 ms
Create / delete 5,000 files 1,160 / 334 ms 160-256 / 23 ms
Spawn a process 17.9 ms 0.6 ms
tsc --noEmit 0.7 s 0.9-1.0 s

CPU-bound work didn't get faster. tsc got a little slower.

Copy, don't move

rsync copied 32 repos, 86 GB, between 16:47 and 17:08, with zero errors. It skipped node_modules, .next, .turbo, .expo and .venv, restored executable bits from the git index, and never wrote to the Windows side. A rollback script could undo every Windows change.

node_modules belongs to one OS

Native packages ship per platform. esbuild has @esbuild/win32-x64 and a Linux twin, and an install only fetches the one for the OS running it. So every repo got a fresh install inside Linux: 40 folders, each with the package manager Windows had used, none allowed to change a lockfile.

  • npm rewrote a yarn.lock and pnpm edited a pnpm-workspace.yaml. The script now snapshots both and restores them.
  • The first yarn and pnpm runs exited 127, command not found, until corepack went on the PATH.
  • One repo's pnpm settings approved only the Windows build of an ffmpeg package, so the Linux one never ran its setup step.

Two of my CLIs still run from Windows out of the Linux tree. Bun 1.4 can put every platform's packages in one node_modules:

bun install --os='*' --cpu='*'

That's the exception. Everywhere else the rule is: never bun install or start a dev server from the Windows side.

Windows can't watch files inside WSL

Windows reads WSL files over \\wsl.localhost, which is the 9P protocol. My file browser, before and after:

On the WSL code folder Windows over 9P Helper inside WSL
Size of 32 repos, 122.63 GB 153.8 s 4.06 s
A file touched in WSL shows up not within 30 s 248-309 ms

The fix is a helper process inside the distro that lists, walks and watches, and talks JSON over wsl.exe stdio.

Inside Linux the limit is inotify. I raised fs.inotify.max_user_watches to 2,097,152 on day one. The helper's recursive inotifywait then held 1.3 million watches, and nine copies of it later held 2.09 million, enough to give every watcher in the distro ENOSPC. The cause is in Four leaks my agents wrote.

Two gits on one repo

Windows git, pointed at a WSL repo:

fatal: detected dubious ownership in repository at '//wsl.localhost/<distro>/<repo>'

git -c safe.directory='*' gets a read-only command through. Don't go further than that. Windows git on a WSL repo is slow and overwrites the Linux side's index, and Linux git on a Windows-drive repo rewrites the Windows index with Linux stat data. Linux git for WSL repos, git.exe for Windows ones.

One folder had three names

My task board keys projects by path. After the move the same repo arrived as a Linux path, a drive-letter path and a \\wsl.localhost\... path, and each became its own project. One shared normalizer folded 60 duplicates: 169 projects became 109.

The other path traps:

  • Git Bash treats backslashes as escapes, so a pasted X:\code\app arrives as X:codeapp. Commands for Git Bash use forward slashes.
  • zsh doesn't word-split unquoted variables. An agent's loop passed 30 repo names as one directory. CLAUDE_CODE_SHELL=/bin/bash makes Claude Code's Bash tool run bash.
  • Windows junctions can't target \\wsl.localhost. Directory symlinks (mklink /D) can, unelevated, with Developer Mode on.

Line endings: convert only files with no real edits

Windows git with core.autocrlf=true had left CRLF in the working trees. Checking a file out again does nothing, because git thinks it's up to date. Delete it first:

git config --global core.autocrlf input
git ls-files --eol | awk -F'\t' '{split($1,m," ")} m[1]=="i/lf" && m[2]=="w/crlf" {print $2}' |
  while IFS= read -r f; do
    git diff --quiet -- "$f" && rm -- "$f" && git checkout -q -- "$f"
  done

Files with uncommitted edits fail git diff --quiet and stay as they are.

Localhost is shared, with two quirks

Mirrored networking (networkingMode=mirrored in .wslconfig) shares localhost both ways: Windows Chrome opens WSL dev servers, and WSL reaches Postgres on Windows. In my sessions, curl to a closed port hung instead of failing, and a port looked taken with nothing listening. The next port up worked.

The VM kept memory it wasn't using

vmmemWSL had 61 GB committed for 14 GB of real use, because Linux fills free RAM with file cache. autoMemoryReclaim=gradual only reclaims while the VM is idle, and under [wsl2] WSL 3.0.3 ignored it as an unknown key. It belongs under [experimental]. A systemd timer now reclaims cache above 8 GB every 10 minutes. One manual drop took vmmemWSL from 61.4 to 42.7 GB.

What stayed on Windows

.NET and WPF tools, tray apps, my file browser and git client, and anything that drives Claude in Chrome. In my words at the time: "claude code doesnt support chrome in wsl, so i need to use powershell for that anyway."

One copy of every repo

On Nov 4 I wrote: "its just not working having this duplication." An agent inventoried the 37 repos that existed on both sides. 31 Windows copies held nothing new. Six held:

  • an unpushed commit, with the Linux copy a month behind
  • two unpushed commits on a branch
  • uncommitted code from the day before
  • a branch that existed nowhere else
  • the deploy portal's live database and two unpushed commits
  • notes in two repos

Each was rescued into the Linux copy. Then all 37 Windows copies were renamed into an archive folder on the same drive, with nothing deleted. My agent instructions now say every repo lives in exactly one place: WSL for anything JS/TS, Windows only for a Windows-native app.

Three weeks from the first copy to one copy of every repo.
Three weeks from the first copy to one copy of every repo.

Checklist for moving a JS/TS repo into WSL

  1. Time bun install and git status on both sides first. If your work is CPU-bound, the move buys little.
  2. Copy into the Linux filesystem, not /mnt/c. Skip node_modules and build caches.
  3. Set core.autocrlf input and convert CRLF only in files with no real edits.
  4. Reinstall inside Linux with the same package manager. Snapshot lockfiles first, enable corepack.
  5. Never install or run a dev server from Windows on a WSL repo. A Windows tool that must run from the Linux tree gets bun install --os='*' --cpu='*'.
  6. Raise fs.inotify.max_user_watches, and count watches whenever something watches recursively.
  7. One git per repo: Linux git in WSL, git.exe on Windows.
  8. If your shell is zsh, set CLAUDE_CODE_SHELL=/bin/bash.
  9. Turn on mirrored networking, and put autoMemoryReclaim under [experimental].
  10. Normalize paths anywhere you key data by path.
  11. Rescue what only the old copy has, archive it, and never keep both.