Skip to content

refactor: unify subdir preservation for wt step relocate#3346

Open
worktrunk-bot wants to merge 1 commit into
mainfrom
unify-subdir-relocate
Open

refactor: unify subdir preservation for wt step relocate#3346
worktrunk-bot wants to merge 1 commit into
mainfrom
unify-subdir-relocate

Conversation

@worktrunk-bot

@worktrunk-bot worktrunk-bot commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Context

Follow-up to #3344, where @max-sixty asked:

great! can we consider unifying these functions so, for example, we get the same behavior with wt merge — basically anything switching paths?

Current state

I traced every site that emits a cd to move the shell between worktrees (change_directory call sites):

Command Path Subdir helper
wt switch / switch --create handlers.rs:931 resolve_subdir_in_target
wt remove / wt merge handlers.rs:1622 resolve_subdir_in_target
wt step relocate relocate.rs:543 hand-rolled dest.join(relative)

So wt merge was already unified — it lands via the same handler as wt remove, so #3344 gave it subdir preservation for free. The one remaining outlier that hand-rolled its own logic was wt step relocate.

Change

Route relocate's shell cd through the shared resolve_subdir_in_target helper, keeping its existing "only cd when the user is inside the moving worktree" guard (cwd.starts_with(src)) — the helper alone would wrongly cd a user who wasn't inside.

The concrete behavior change is the is_dir() fallback: the old code did an unconditional dest.join(relative), so a missing subdir at the destination would have produced a cd to a nonexistent path; the helper falls back to the worktree root instead. It's a no-op in practice (git worktree move carries the whole subtree, so the subdir always exists at the destination), but it keeps the behavior identical across all three commands.

Note the helper's symlink canonicalization does not materialize for relocate. git worktree move runs (relocate.rs:531) before this cd block, so by the time the helper canonicalizes src_path and cwd, neither exists on disk — both dunce::canonicalize calls fail and fall back to the raw paths. Relocate therefore keeps its pre-existing raw starts_with/strip_prefix string match and, unlike switch/remove (which canonicalize while their source still exists), does not survive symlinked cwds. No behavior regression — it degrades gracefully to the prior string-matching behavior.

The helper is now pub(crate) with an updated docstring naming its three callers.

Testing

  • Added test_relocate_preserves_subdir — relocating a worktree from inside apps/gateway/ lands the cd directive in the equivalent subdir at the new location.
  • The existing unit tests for resolve_subdir_in_target and the switch/remove subdir integration tests continue to pass.
  • Full relocate test filter: 26 passed, 0 failed.

🤖 Generated with Claude Code

max-sixty asked (in #3344) whether the subdirectory-preservation logic
could be unified so every path-switching command behaves the same. `wt
switch` and `wt remove`/`wt merge` already share `resolve_subdir_in_target`
(merge lands via the same handler). The one remaining path that hand-rolled
its own `dest.join(relative)` was `wt step relocate`.

Route relocate's shell `cd` through the same helper, keeping its "only cd
when the user is inside the moving worktree" guard. This also gains the
helper's symlink canonicalization (relocate previously did a raw
non-canonical `strip_prefix`) and the read-only `is_dir()` fallback (a no-op
here since `git worktree move` carries the whole subtree, but keeps behavior
identical across commands).

Adds `test_relocate_preserves_subdir`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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 unification is clean and the calling convention matches the switch/remove sites. One note on the stated benefits, since it's easy to over-read: the symlink-canonicalization gain doesn't actually materialize for relocate. git worktree move runs at relocate.rs:531, before the cd block at 538–550, so by the time resolve_subdir_in_target calls dunce::canonicalize on src_path and cwd, neither path exists on disk anymore — both canonicalize calls fail and fall back to the raw paths (unwrap_or_else). So relocate effectively runs the same raw strip_prefix it did before, just with the added is_dir() fallback.

That's fine — it degrades gracefully to the prior behavior and the is_dir() fallback is a strict improvement — but the PR body frames symlink survival as an active gain here, and it isn't. switch/remove canonicalize before their source is gone, so they get it; relocate can't. Worth trimming that bullet so a future reader doesn't assume relocate survives symlinked cwds when it still relies on the pre-existing starts_with/strip_prefix string match.

No code change needed — the behavior is correct.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

The red test (windows) check is a real, Windows-specific failure in the new test, not a flake. Here's the root cause and why I'm flagging it as a design question rather than pushing a fix.

What fails. test_relocate_preserves_subdir spawns wt with its CWD set inside the worktree being relocated (.current_dir(wrong_path.join(&subdir)), i.e. wrong-location/apps/gateway). The git worktree move then fails:

fatal: failed to move '.../wrong-location' to '.../repo.feature': Permission denied

On Windows, renaming a directory tree fails with a sharing violation if any live process holds a current directory inside that tree. Here the spawned wt process is that holder — its CWD is wrong-location/apps/gateway, and git worktree move is trying to rename wrong-location.

Why remove doesn't hit this but move does. The sibling test_remove_preserves_subdir also stands inside via current_dir(feature_wt.join(subdir)) and passes on Windows. The difference is the git operation: git worktree remove tolerates a live CWD inside the target, but git worktree move is a directory rename, which Windows refuses. This is the one path-switching command that physically renames the source, so it's the only one exposed to the lock — which is why the unification surfaced it and remove/switch never did.

The part I could not verify from CI. Relocate takes its cwd from the physical process directory (std::env::current_dir()), so exercising the subdir-preservation path requires wt to physically stand inside the worktree — there's no $PWD-style logical override to point the guard inside while standing elsewhere. That leaves two dispositions, and which is right depends on Windows behavior I can't reproduce in this Linux CI sandbox:

  • If the intent is that wt step relocate should work when the user is standing inside the moving worktree, then on Windows the user's shell also holds that CWD for the duration of the command, so git worktree move would fail for a real user too — the test is catching a genuine limitation. In that case, having wt release its own CWD before the move would turn the test green but wouldn't fix the real case (the shell's lock remains), i.e. a false green. My reasoning here is unverified — I haven't run it on Windows.
  • If relocate-from-inside is out of scope on Windows, the honest fix is to gate the inside-standing assertion (#[cfg_attr(windows, ignore)] or a cfg-split assertion) with a comment naming the git worktree move rename constraint, so the test documents the boundary instead of masking it.

I'm not pushing either change since I can't confirm the real-user Windows behavior end-to-end from here — flagging it so the direction is a deliberate call. Linux and macOS (including the advisory legs) are green, so the logic itself is sound; this is purely the Windows move-while-inside constraint.

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