DEV Community

Ops Panel
Ops Panel

Posted on

git worktree remove --force emptied my main node_modules (Windows junctions)

I let an AI agent build a deploy script for my own ops panel. The script created a git worktree, built there, then cleaned up with git worktree remove --force.

After the cleanup, the main checkout's node_modules was empty. Not the worktree's. The main one.

Here is what actually happened, why recovery took 13 seconds, and the thing that scared me more than the outage.

The setup that caused it

The idea was reasonable: give the agent a scratch copy of the repo so it never touches the working tree I'm using.

repo/                 <- main checkout
repo-build/           <- git worktree, created per deploy
Enter fullscreen mode Exit fullscreen mode

node_modules is big and slow to install, so the script made the worktree's node_modules a junction pointing at the main one:

mklink /J repo-build\node_modules repo\node_modules
Enter fullscreen mode Exit fullscreen mode

A junction is the NTFS equivalent of a directory symlink. "The contents of this folder actually live over there."

Then, at the end of the deploy:

git worktree remove --force repo-build
Enter fullscreen mode Exit fullscreen mode

The finding: Git for Windows walks into junctions

I reproduced it deliberately, because an accident you can't reproduce is a rumour.

  • Git for Windows treats a junction as an ordinary directory and deletes its contents recursively. It follows the link and removes the files on the other side — in the main checkout.
  • rmdir in cmd does not follow it. It removes the link itself and leaves the target alone.

Same apparent operation ("delete this folder"), opposite blast radius, depending on which tool does the deleting. --force meant no prompt and no warning on the way through.

If you take one thing from this post: do not create a link from a place that gets deleted to a place that matters. Junctions, directory symlinks, bind mounts — the rule is the same. The link is not the only thing a cleanup routine will remove.

Recovery was boring, and that was the point

pnpm install --frozen-lockfile
Enter fullscreen mode Exit fullscreen mode

13 seconds. Done.

It was fast because the lockfile was committed. node_modules is not data; it is a build artifact with an exact recipe sitting in the repo. --frozen-lockfile reinstalls precisely what the recipe says, or fails loudly.

The outage was never the interesting part.

The interesting part: what if the junction had pointed at data/?

My panel keeps all of its business data as JSON files on local disk. No database server. Orders, daily records, customer messages, configuration — files.

And those files lived inside the same repository the agent was creating and destroying worktrees in.

The accident drew a line I had never drawn explicitly:

Category Examples If deleted
Reproducible node_modules, build output, caches One command, with a lockfile
Not reproducible business JSON, hand-written config No way back

I had been protecting the first category (lockfile committed, good) and doing nothing at all for the second.

What I changed

1. Backups go outside the repository

Every 30 minutes, the durable JSON is copied to a timestamped directory outside the project tree:

%LOCALAPPDATA%\<app>\backups\data-YYYYMMDD-HHMMSS\
Enter fullscreen mode Exit fullscreen mode

"Outside" matters more than "every 30 minutes." The lesson of the incident is that a deletion inside the repo can reach anything inside the repo:

  • worktree cleanup
  • an agent's own tidy-up command
  • any deploy step that does "remove the folder, then recreate it"

A backup/ folder inside the project is not a backup. It dies with everything else.

2. The copy is crash-safe and bounded

Two details that are easy to skip and annoying to discover later:

  • Write to a temp directory, then rename. If the process dies mid-copy you get no snapshot rather than half a snapshot. Rename is atomic; a directory full of partially written JSON is not.
  • Keep N and prune. Every 30 minutes is 48 directories a day. Decide the retention (I keep the last 24 ≈ 12 hours) and delete the rest, or you will find out when the disk fills.

Also: exclude the churn. Heartbeats, *.pid, lock files, cached market data I can refetch in seconds — none of it belongs in a snapshot. One of my caches was 70 MB and turned 24 snapshots into 1.7 GB before I noticed.

3. Restore once, on purpose

A backup you have never restored is a hypothesis. Stop the app, copy a snapshot back over data/, start it, confirm the screens are right. Until you do that, you have a directory of files, not a backup.

4. No junctions in worktrees

Each worktree now runs its own pnpm install --frozen-lockfile and owns its node_modules. The sharing was meant to save install time; the lockfile already made install fast. Trading tens of seconds for "a cleanup can reach into main" was a bad deal.

(Separately, Turbopack rejects a node_modules symlink pointing outside the project anyway, so the clever version was never going to survive.)

Why this bites harder with agents

When you hand work to an agent, directories get created and destroyed while you are not watching. The commands are usually fine in isolation. What changes is that nobody is reading the --force before it runs, and the blast radius of a wrong assumption is no longer bounded by your attention.

So the defence is structural, not behavioural:

  1. Reproducible things are protected by a committed lockfile.
  2. Non-reproducible things are copied outside the agent's reach, automatically.
  3. No links from deletable places to important places.

None of this needs a tool. It is "put it in a different folder and copy it on a timer." I just wasn't doing it until something ate a directory in front of me.

If you are running agents against a repo that also holds your real data: go look at where that data lives, and ask how you would get it back. That question took me 13 seconds to answer for node_modules and uncomfortably long to answer for everything else.

Top comments (0)