October 14, 2026·4 min read

The grep that matched itself

My cleanup script decided every checkout was in use, including the ones that were plainly idle.

By Andrew Pyle

The safety check in my cleanup script had a spotless record, because it refused every path it was ever asked about.

01Every line said skip

A dry run that refused everything

My cleanup script printed one line for every worktree and every sibling checkout it looked at:

SKIP in-use(live proc)

I had run it as a dry run with --strip-deps --sweep-checkouts --min-idle-days 1, which asks it to clear dependency folders out of idle trees and sweep old checkouts. It found nothing to do. Every path was held back as in use, including zoo_aquarium_guide, an idle checkout that nothing was touching.

A dry run is exactly where I want a guard like this to be loud, and it was. It was loud about the wrong thing, though, and it said the same thing about every path. One answer for paths that were plainly different should have been my first clue.

02Why the script exists

Eight sessions and a heavy disk

I often run about eight Claude Code sessions at once, each in its own git worktree. That pattern bogged the Mac down. Each worktree carried its own virtual environment of about 1.4 GB, merged branches left stale worktrees behind, and every session ran its own file watchers.

So I wrote worktree_gc.sh. It is a dry run by default, and it removes a worktree only when it is clean and merged: the remote branch is gone or the head is already in main, there are no tracked changes, and there are no untracked .env files. The first sweep reclaimed about 25 GB across 15 worktrees. Later a nightly scheduled job started running the same script on its own, which changes what a bug in it means. A broken guard in a manual tool costs me one confused run. A broken guard in an unattended job stops the job every night, and unattended output is easy to miss.

The guard that failed was one day old. The day before, a manual cleanup had found two management commands I had never committed, sitting untracked in worktrees the script was about to remove. git worktree remove --force deletes files like that for good, because they are in no commit and on no branch. Only a manual pre-check had caught them. So I added a refusal for worktrees with untracked files that are not build junk, and that change put the in-use check in front of every removal, strip, and sweep.

03grep found grep

The search was in the list it searched

The in-use check was one line. It lists every running command line, drops the script's own lines, then looks for the path:

ps axo command 2>/dev/null | grep -vF 'worktree_gc.sh' | grep -qF "$1"

The problem is timing. A shell pipeline starts all of its parts together, so by the time ps takes its list, the grep that will search that list is already running. Its command line contains the path, because the path is its argument. So the last grep found itself, every time, for every path. There was no live process using zoo_aquarium_guide. There was a live process mentioning it, and that process was my own check asking the question.

What made this one slippery is that I had already thought about self-matching. The grep -vF 'worktree_gc.sh' step exists to remove the script's own command line from the list. I removed one reflection and missed the second one, the grep doing the search. A check that guards against half of a mistake reads as if it guards against all of it. When I tested the old function directly on the idle path, it matched 10 times out of 10.

04Snapshot, then search

Take the list before you search it

The change is six lines added and one removed, in one function. It captures the process list into a local variable first, then pipes that copy through the same two greps:

path_in_use() {
  local procs
  procs="$(ps axo command 2>/dev/null)"
  printf '%s\n' "$procs" | grep -vF 'worktree_gc.sh' | grep -qF "$1"
}

The grep doing the search is not in the snapshot, because the snapshot was taken before that grep existed. The filter on the script's own name stayed. On the idle path the new version gave 0 matches out of 10. Against a real live process, a sleep 30, it still gave 10 out of 10, which is the half I could not afford to lose. I kept the change that small on purpose. Rolling it back is a plain revert, and the old behavior fails safe.

The bug failed in the safe direction. A guard that skips everything deletes nothing, so nothing broke. That is also why it hid. The nightly job had been doing nothing since the guard shipped, and the only sign was a dry run that had to be read line by line.

Some limits stay written down. ps shows command lines, not working directories, so it catches a tool running in a tree but not a shell or an editor merely sitting in one; a separate recently-touched check covers that case. A process that starts between the snapshot and the action can still slip past, which is the same race the old version had. And I have not yet watched a full --apply run with the fixed script. The next dry run should show WOULD-STRIP-DEPS lines for idle trees and in-use only where something really runs. Until I see that line, I have a good argument for the fix and no result yet.

Have something you need built or fixed?

I build production Django / Next.js platforms and human-supervised AI-agent systems. Solo, senior, and fast. Tell me what you are building.

Start a project