From b6592ebc81b2c6257429eb815cf456545ad4963d Mon Sep 17 00:00:00 2001 From: worktrunk-bot Date: Wed, 1 Jul 2026 07:55:38 +0000 Subject: [PATCH 1/2] refactor: unify subdir preservation for wt step relocate 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 --- src/commands/relocate.rs | 12 ++++-- src/output/handlers.rs | 15 ++++++-- tests/integration_tests/step_relocate.rs | 48 +++++++++++++++++++++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/commands/relocate.rs b/src/commands/relocate.rs index 7c3ea3d0a0..b0ca537a9e 100644 --- a/src/commands/relocate.rs +++ b/src/commands/relocate.rs @@ -535,12 +535,18 @@ impl<'a> RelocationExecutor<'a> { let msg = cformat!("Relocated {branch}: {src_display} → {dest_display}"); eprintln!("{}", success_message(msg)); - // Update shell if user is inside this worktree + // Update shell if user is inside this worktree, preserving their + // subdirectory position via the same helper as `switch`/`remove` so + // every path-switching command behaves identically. if let Some(cwd_path) = cwd && cwd_path.starts_with(&src_path) { - let relative = cwd_path.strip_prefix(&src_path).unwrap_or(Path::new("")); - crate::output::change_directory(dest_path.join(relative))?; + let cd_target = crate::output::handlers::resolve_subdir_in_target( + &dest_path, + Some(&src_path), + cwd_path, + ); + crate::output::change_directory(cd_target)?; } self.moved.insert(idx); diff --git a/src/output/handlers.rs b/src/output/handlers.rs index 5222858ebc..2886ccc1bb 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -859,12 +859,21 @@ fn print_switch_message_if_changed( Ok(()) } -/// Compute the target directory for `cd` after switching, preserving the user's -/// subdirectory position when possible. +/// Compute the target directory for `cd` when moving the shell between +/// worktrees, preserving the user's subdirectory position when possible. /// /// If the user is in `source_root/apps/gateway/` and `target_root/apps/gateway/` /// exists, returns `target_root/apps/gateway/`. Otherwise returns `target_root`. -fn resolve_subdir_in_target(target_root: &Path, source_root: Option<&Path>, cwd: &Path) -> PathBuf { +/// +/// Shared by every command that relocates the shell — `switch`, `remove` (and +/// `merge`, which lands via the same handler), and `step relocate` — so they +/// preserve subdirectory position identically (canonicalizing to survive +/// symlinks, and falling back to the root when the subdir is absent). +pub(crate) fn resolve_subdir_in_target( + target_root: &Path, + source_root: Option<&Path>, + cwd: &Path, +) -> PathBuf { if let Some(source_root) = source_root { // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS) let cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); diff --git a/tests/integration_tests/step_relocate.rs b/tests/integration_tests/step_relocate.rs index 7bf147020d..ba550fe253 100644 --- a/tests/integration_tests/step_relocate.rs +++ b/tests/integration_tests/step_relocate.rs @@ -1,9 +1,12 @@ //! Integration tests for `wt step relocate` -use crate::common::{TestRepo, make_snapshot_cmd, repo}; +use crate::common::{ + TestRepo, configure_directive_files, directive_files, make_snapshot_cmd, repo, +}; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; use std::fs; +use std::path::Path; /// Get the parent directory of the repo (where worktrees are created) fn worktree_parent(repo: &TestRepo) -> std::path::PathBuf { @@ -1024,3 +1027,46 @@ worktree-path = "../{{ undefined_var }}.{{ branch }}" "template_error skip missing from JSON: {parsed}" ); } + +/// Relocating a worktree the user is standing inside preserves their +/// subdirectory position, routing the `cd` through the same +/// `resolve_subdir_in_target` helper as `switch`/`remove` (issue #3343 unify). +#[rstest] +fn test_relocate_preserves_subdir(repo: TestRepo) { + let parent = worktree_parent(&repo); + let (cd_path, exec_path, _guard) = directive_files(); + + // Create a worktree at a non-standard location, with a subdirectory the + // user is working in. + let wrong_path = parent.join("wrong-location"); + repo.run_git(&[ + "worktree", + "add", + "-b", + "feature", + wrong_path.to_str().unwrap(), + ]); + let subdir = Path::new("apps").join("gateway"); + fs::create_dir_all(wrong_path.join(&subdir)).unwrap(); + + let mut cmd = repo.wt_command(); + configure_directive_files(&mut cmd, &cd_path, &exec_path); + cmd.args(["step", "relocate"]) + .current_dir(wrong_path.join(&subdir)); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "wt step relocate failed: {output:?}" + ); + + // The cd directive should land in the equivalent subdirectory of the + // worktree's new location, not at its root. + let cd_content = fs::read_to_string(&cd_path).unwrap_or_default(); + let expected_subdir = parent.join("repo.feature").join(&subdir); + let expected_str = expected_subdir.to_string_lossy(); + assert!( + cd_content.contains(&*expected_str), + "CD file should contain relocated subdirectory path {expected_str}, got: {cd_content}" + ); +} From b995c6bf67d3803507645ef7247d97d5892e5162 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 21 Jul 2026 19:29:43 -0700 Subject: [PATCH 2/2] test: gate relocate-from-inside subdir test to Unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_relocate_preserves_subdir stands the spawned wt inside the worktree being moved — the only way to exercise subdir preservation on relocate, since the cd fires only when cwd is inside src. On Windows, git worktree move is a directory rename that fails with a sharing violation while a live process holds that cwd, so the test cannot run there. Unlike remove (where shell integration cds to main before removing), a real Windows user hits the same wall: their shell holds the cwd across the move. Gate the test with #[cfg_attr(windows, ignore)], matching the existing remove.rs pattern, and document the boundary rather than mask it by having wt release its own cwd — that would go green while the real case still fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests/step_relocate.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/integration_tests/step_relocate.rs b/tests/integration_tests/step_relocate.rs index ba550fe253..ea9bb5835f 100644 --- a/tests/integration_tests/step_relocate.rs +++ b/tests/integration_tests/step_relocate.rs @@ -1031,7 +1031,15 @@ worktree-path = "../{{ undefined_var }}.{{ branch }}" /// Relocating a worktree the user is standing inside preserves their /// subdirectory position, routing the `cd` through the same /// `resolve_subdir_in_target` helper as `switch`/`remove` (issue #3343 unify). +/// +/// Ignored on Windows: subdir preservation only fires when the cwd is inside the +/// moving worktree — which is exactly when `git worktree move` (a directory +/// rename) fails with a sharing violation, because a live process holds that cwd. +/// Unlike `remove` (where shell integration cds to main before removing), a real +/// Windows user hits this too: their shell holds the cwd across the move. So the +/// preservation path is reachable, and testable, only on Unix. #[rstest] +#[cfg_attr(windows, ignore)] fn test_relocate_preserves_subdir(repo: TestRepo) { let parent = worktree_parent(&repo); let (cd_path, exec_path, _guard) = directive_files();