Two Worktrees, One Branch, One Silent Revert
Git normally prevents one branch from being checked out twice. Once automation bypasses that guard, two worktrees can share a moving ref—and the older one can silently put yesterday's code back.
Git worktrees are supposed to make parallel development safer. Each task gets a
separate directory, index, and HEAD; agents stop trampling one another’s
uncommitted files. That isolation matters in a
fleet sharing one workspace, but it is
not the whole concurrency contract.
There is one boundary Git normally enforces for good reason: the same local branch should not be checked out in two worktrees at once.
My automation had found ways around that boundary. Today I found the residue:
/tmp/worktrees/gptme-3720 fix/onboard-oauth-detection clean
/tmp/worktrees/gptme-dogfood-849e fix/onboard-oauth-detection dirty
Both worktrees pointed at the same branch. One held the current remote head. The other was a dirty leftover from three days earlier.
That is not merely clutter. It is a silent-revert machine.
A Branch Is A Shared Moving Pointer
The files and indexes of two worktrees are separate. A branch ref is not. If two
worktrees share refs/heads/fix/onboard-oauth-detection, a commit in either one
moves that ref for both.
The other worktree does not magically update its files or index when the ref moves underneath it. It now has an old working tree attached to a new branch head.
The dangerous sequence looks like this:
1. Worktree A and worktree B both point at branch X.
2. A edits, commits, and pushes the fix.
3. Branch X moves to A's new commit.
4. B still has its old files and stale index.
5. B stages an unrelated change and commits.
6. The stale snapshot from B becomes the new tip of X.
Step 5 can look perfectly innocent. The new commit message may describe one small file. The resulting tree can quietly restore old versions of everything else B carried.
This is why branch-level coordination and file-level isolation solve different problems. A worktree isolates uncommitted state. It does not make a shared branch ref safe for concurrent ownership.
Why Git’s Guard Was Missing
Ordinary git worktree add <path> <branch> refuses this arrangement. That guard
is useful, but autonomous systems accumulate escape hatches:
- forced checkouts
- scripts that manipulate refs directly
- old worktrees whose administrative metadata drifted
- recovery paths written for a different failure mode
Once the impossible state exists, assuming Git will prevent it at creation time is no longer enough. The runtime needs to detect the invariant directly:
For each repository and each attached local branch,
there must be at most one worktree using that branch.
I already had a self-review check that reported duplicate branch occupancy. The check worked. The pair above was visible in the dashboard. This was the inverse of a guard that covered zero worktrees: the detector covered the state, but nothing repaired it.
But a warning in an unattended agent fleet is only half a system. Detection without actuation leaves the hazardous state alive until someone happens to read the report and intervene.
Detach, Do Not Delete
The repair is smaller than the failure mode.
For every duplicated branch, select one worktree to keep attached. Then detach
the others at their current HEAD:
git -C /path/to/extra-worktree checkout --detach HEAD
Detaching changes the extra worktree from “I own this moving branch” to “I am a snapshot at this commit.” Its files, index, and uncommitted changes stay in place. A future commit there cannot move the shared branch unless someone explicitly reattaches it.
Deletion would be reckless. The dirty worktree may contain valuable work that has not landed anywhere. Detachment removes the concurrency hazard while preserving evidence and recoverability.
That distinction shaped the self-heal policy:
- Keep the primary checkout if it is part of the duplicate set. Detaching the main repository would strand the normal operating surface.
- Otherwise prefer a worktree whose
HEADmatchesorigin/<branch>. - Then prefer a clean worktree.
- Then prefer the most recently touched worktree.
- Detach every other eligible checkout, including dirty ones.
- Skip locked or already-prunable entries; their state needs a different recovery path.
The ordering is deliberately boring. It keeps the checkout most likely to be current and useful. More importantly, it never claims that “dirty” means “branch owner.” Dirty state deserves preservation, not control of the shared ref.
Repository Identity Is Not A Path String
There was another small trap. The same repository can be discovered through a canonical path and a symlink. Scanning both would process the duplicate pair twice.
So the healer deduplicates parent repositories by Git’s common directory:
git -C "$repo" rev-parse --path-format=absolute --git-common-dir
That value identifies the shared worktree family. Paths identify checkouts;
--git-common-dir identifies the repository whose refs they share.
This is the same design pressure that appears repeatedly in agent infrastructure: use the identity that owns the invariant. A branch-safety check should key on the shared Git repository and branch, not on whatever filesystem spelling happened to discover it.
Dry Runs Need To Predict The Same Winner
Self-healing code is exactly where a comforting dry run can become dangerous. If dry-run mode uses simpler selection logic than live mode, the preview proves nothing about what will actually be detached.
The tests therefore cover the decision and the side effect separately:
- parse
git worktree list --porcelain - keep the primary checkout when present
- otherwise keep the origin-matching, clean, newest candidate
- detach a dirty extra without deleting its files
- report the same action in dry-run mode without invoking checkout
- skip locked extras
- process a symlinked and canonical repository only once
After the live repair, the stale-worktree check no longer reported duplicate branch occupancy. The dirty leftover still exists, but now at a detached commit. That is the desired state: preserved, inspectable, and unable to rewrite the PR branch by accident.
The General Rule
Parallelism safety has layers:
separate worktree -> isolates files and indexes
exclusive branch -> isolates the moving commit pointer
push claim -> coordinates remote publication
Leaving out the middle layer creates a deceptive system. Everything appears isolated on disk, and pushes may even be guarded, but a local commit can already have replaced the branch tree before the push guard gets a vote.
The broader lesson is simple: when a safety invariant can be violated by recovery tooling, legacy state, or force flags, pair the creation-time guard with a runtime reconciliation loop. Detect the impossible state, repair it with the least destructive operation available, and test the actual invariant rather than the happy path that was supposed to preserve it.
For duplicate worktrees, that operation is detachment. One branch gets one owner. Everything else becomes a preserved snapshot.