Skip to content

fix(relocate): skip worktree blocked by an immovable occupant - #3530

Merged
max-sixty merged 3 commits into
mainfrom
nightly/relocate-blocked-occupant-29809337058
Jul 26, 2026
Merged

fix(relocate): skip worktree blocked by an immovable occupant#3530
max-sixty merged 3 commits into
mainfrom
nightly/relocate-blocked-occupant-29809337058

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

wt step relocate could strand a worktree in the internal staging directory and then error out.

When worktree A's target is occupied by worktree B, A depends on B vacating. But if B is itself blocked — its own target is a non-worktree path and --clobber wasn't passed — B never moves. The dependency-resolution loop's no-progress branch assumes "no progress ⟹ a cycle to break," so it temp-moved A into .git/wt/staging/relocate/. finalize_temp_relocations then ran git worktree move <temp> <A's target> into the still-occupied path, which fails and propagates via ? — leaving A relocated into the staging dir rather than at either its original or expected path.

Reproduction

  • A at /w/a wants /w/b; B at /w/b wants /w/c; /w/c is a plain non-worktree file, no --clobber.
  • B is classified target_blocked and never moves.
  • A is temp-moved by break_cycle, then finalize fails with "Failed to move worktree from temp to final location", stranding A in staging.

Fix

is_target_empty only distinguished "occupant moved" from "occupant present"; it couldn't tell a still-pending occupant from a permanently-blocked one. A new blocked_occupant helper detects when a dependent's target is held by a worktree already in the blocked set, and the loop propagates the block to the dependent (skipping it cleanly) instead of handing it to break_cycle. Setting made_progress re-drives the loop so a chain of dependents (A → B → C-blocked) is blocked transitively.

After the fix both worktrees are skipped and the command succeeds:

▲ Skipping beta (target blocked: repo.beta)
↳ To backup blocking paths, use --clobber
▲ Skipping alpha (blocked by beta, which can't be relocated)

○ Relocated 0 worktrees, skipped 2 worktrees

Test

test_relocate_blocked_occupant_skips_dependent sets up exactly this shape and asserts the command succeeds, both worktrees stay put, and nothing is stranded in .git/wt/staging/relocate/. Before the fix the command exited non-zero and alpha was left in the staging dir, so both the snapshot (success: true) and the location assertions would have failed.

Found during the nightly code-quality survey of src/commands/relocate.rs.

When a worktree's target is occupied by another worktree that is itself
blocked (its own target is a non-worktree path with no --clobber), the
dependent could never reach its target. The no-progress branch treated it
as a cycle, temp-moved it into the staging dir, and finalize then failed
moving it into the still-occupied target — erroring out and stranding the
worktree in .git/wt/staging/relocate/.

Detect a blocked occupant and propagate the block to its dependents so
they are skipped cleanly instead of temp-moved.
@worktrunk-bot worktrunk-bot added the nightly-cleanup Issues found by nightly code quality sweep label Jul 21, 2026
The guard's only caller (is_target_empty's Some(false) arm) already
guarantees the target exists, and canonicalize's fallback returns None for
an absent path anyway — so the early return was dead code and the one line
codecov/patch flagged as uncovered. Remove it.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct: blocked_occupant cleanly diverts a dependent away from break_cycle when its occupant is in the blocked set, and setting made_progress = true re-drives the loop so a longer chain (A → B → C-blocked) blocks transitively. Traced the classification-then-execute ordering, the Some(false) arm, and the finalize stranding path — all sound. This is a self-review, so submitting as a comment rather than an approval.

One coverage note: test_relocate_blocked_occupant_skips_dependent is a 2-level chain (alpha → beta-blocked), where beta is already in blocked from new()'s classification. In that shape alpha is blocked on the first pass, and nothing else remains — so the test still passes even if made_progress = true were removed (the loop would fall straight into break_cycle, find everything blocked, and return false). The transitive re-drive the PR body calls out is only exercised by a 3+ level chain (A → B → C-blocked), where B must be blocked in one pass before A can be blocked in the next. Consider adding a 3-worktree variant so a future refactor can't silently drop the re-drive without a failing test. Not blocking — happy to push that test if useful.

The existing test_relocate_blocked_occupant_skips_dependent is a 2-level
chain (alpha -> beta-blocked) where alpha is blocked on the first pass, so
it passes even without the made_progress re-drive. This adds a 3-level
chain (alpha -> beta -> gamma-blocked) that forces the transitive re-drive
to be load-bearing: alpha is parked at a path (aaa-alpha) whose basename
sorts before beta's repo.alpha, so worktree-list order visits the dependent
before its occupant. That leaves alpha pending after pass 1 and only the
re-drive blocks it in pass 2. Without the re-drive, break_cycle temp-moves
alpha and finalize misplaces it into the occupied repo.alpha.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Pushed the 3-level chain test in b1c85e6 — but building it surfaced a wrinkle worth recording: a naive alpha → beta → gamma-blocked shape doesn't exercise the re-drive on its own.

Worktrees are processed in git worktree list order (git sorts linked worktrees by registration id ≈ path basename), not argument order. If the dependent happens to be iterated after its occupant, both get blocked in a single pass and the re-drive is never needed — so the chain has to force the dependent ahead of its occupant. The test parks alpha at aaa-alpha, whose basename sorts before beta's repo.alpha, so pass 1 visits alpha while beta is still pending, blocks beta, and only the made_progress re-drive runs the pass 2 that blocks alpha.

Verified it's genuinely load-bearing: with made_progress = true removed from the blocked_occupant arm, the test fails — break_cycle temp-moves the still-pending alpha and finalize misplaces it into the occupied repo.alpha (Relocated 1 worktree, skipped 2 instead of skipped 3). Restored, all three skip cleanly. Both the snapshot and a new !repo.alpha/alpha assertion catch the regression.

@max-sixty
max-sixty merged commit cad89ea into main Jul 26, 2026
37 checks passed
@max-sixty
max-sixty deleted the nightly/relocate-blocked-occupant-29809337058 branch July 26, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nightly-cleanup Issues found by nightly code quality sweep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants