Skip to content

Remove worktrees with initialized submodules by passing --force - #226

Merged
scgopi merged 1 commit into
mainfrom
fix/remove-submodule-worktrees
Aug 30, 2026
Merged

Remove worktrees with initialized submodules by passing --force#226
scgopi merged 1 commit into
mainfrom
fix/remove-submodule-worktrees

Conversation

@scgopi

@scgopi scgopi commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Problem

git worktree remove refuses worktrees whose submodules are initialized — fatal: working trees containing submodules cannot be moved or removed (reproduced on git 2.50.1; this repository's own ThirdParty/ghostty + zmx in a loop's worktree is the real-world case). Hygiene's removal passed --force only for dirty trees — and a submodule worktree reads clean (status --porcelain is empty) — so those worktrees were stuck: the sweeper offered them, git refused, the sheet named the refusal, nothing could go through.

Evidence

Setup Result
initialized submodule, clean plain remove → refused; single --force → removed, gitdir bookkeeping clean
.gitmodules present, submodule not initialized plain remove → succeeds
uncommitted change inside a submodule superproject porcelain shows M <sub> — already dirty by graphcode's read
dirty tree + initialized submodule single --force → removed

So: the refusal is about initialized submodules only, one --force always suffices, and no deinit / rm -rf / double-force is ever needed.

Fix

  • WorktreeGitFacts.hasSubmodules: initialized checkouts only. Local detection gates on .gitmodules (zero extra git calls for submodule-free repos) then reads git submodule status (a non-- line means populated); remote is one extra SSH round trip.
  • Removal passes --force when submodules are present — without the discard confirmation: a pristine checkout is restorable from upstream, so the force is bookkeeping, not discard. Uncommitted work inside submodules keeps its protection via the superproject's dirty read (row 3 above).
  • All three removal routes pass the flag: sweeper (fresh-inspection path, discardsFiles vs needsForce split), resolve moment (auto-remove), and card Reclaim — whose action now carries hasSubmodules, because the project scope clears the offer in the same action and the fact must survive it.

Tests

  • WorktreeRemovalTests: clean submodule row is forced straight through, no confirmation raised.
  • WorktreeHygieneTests: submodules change the removal mechanics, not the tier — still safe, still removable, removalDiscardsFiles stays false.
  • 33/33 across WorktreeHygiene + WorktreeSweepFeature + WorktreeRemoval; swiftlint and swift-format clean.

git refuses to remove a worktree whose submodules are initialized, even
when the tree is clean — the error users saw: ``working trees containing
submodules cannot be moved or removed``. Hygiene never passed --force on
a clean row, so those worktrees were stuck.

The facts now carry hasSubmodules (initialized checkouts only —
uninitialized gitlinks were never the problem), read as a non-`-` line
of `git submodule status`, gated on .gitmodules locally so repositories
without submodules pay zero extra git calls; remotely it is one more
round trip.

Forcing for submodules is bookkeeping, not discard: a pristine checkout
is restorable from upstream, so it does not raise the confirmation.
Uncommitted work inside a submodule keeps its protection — the
superproject's status already reports it dirty — and the three removal
routes (sweeper, resolve moment, card Reclaim) all pass the flag; the
card action carries the offer's fact because the offer is cleared in the
same action.
@scgopi
scgopi merged commit 1a3e0f5 into main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant