diff --git a/docs/content/faq.md b/docs/content/faq.md
index bf08cf5b3f..02de93365f 100644
--- a/docs/content/faq.md
+++ b/docs/content/faq.md
@@ -120,7 +120,7 @@ Created by `wt config shell install`:
- **Bash**: adds line to `~/.bashrc`
- **Zsh**: adds line to `~/.zshrc` (or `$ZDOTDIR/.zshrc`)
- **Fish**: creates `~/.config/fish/functions/wt.fish` and `~/.config/fish/completions/wt.fish`
-- **Nushell** : creates `$nu.default-config-dir/vendor/autoload/wt.nu` (typically `~/.config/nushell` on Linux, `~/Library/Application Support/nushell` on macOS)
+- **Nushell** : creates `wt.nu` in Nushell's user vendor-autoload directory — the last entry of `$nu.vendor-autoload-dirs`, under `$nu.data-dir` (typically `~/.local/share/nushell/vendor/autoload` on Linux, `~/Library/Application Support/nushell/vendor/autoload` on macOS)
- **PowerShell** (Windows): creates both profile files if they don't exist:
- `Documents/PowerShell/Microsoft.PowerShell_profile.ps1` (PowerShell 7+)
- `Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1` (Windows PowerShell 5.1)
@@ -183,6 +183,7 @@ Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch
- `wt remove` — in addition to the target worktree, runs a background internal sweep: deletes `.git/wt/trash/` entries older than 24 hours (eventual cleanup for directories orphaned when a previous background removal was interrupted), and terminates (`SIGTERM` then `SIGKILL`) `git fsmonitor--daemon` processes whose worktree no longer exists
- `wt remove` — terminates the removed worktree's `git fsmonitor--daemon` process. Git starts this per-worktree filesystem-watch daemon when `core.fsmonitor=true`; once its worktree is gone it would leak. Removal sends `git fsmonitor--daemon stop`, then resolves that daemon's PID from its IPC socket and force-terminates it (SIGTERM, then SIGKILL) if it didn't exit. The signal only ever targets the daemon whose socket resolves to the worktree being removed.
- `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash)
+- `wt config shell install` — when migrating an integration to a new location, removes the worktrunk-managed file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`)
- `wt config shell uninstall` — removes shell integration from rc files
See [What files does Worktrunk create?](#what-files-does-worktrunk-create) for details.
diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md
index f2485a3415..0ceb2c5b8a 100644
--- a/skills/worktrunk/reference/faq.md
+++ b/skills/worktrunk/reference/faq.md
@@ -113,7 +113,7 @@ Created by `wt config shell install`:
- **Bash**: adds line to `~/.bashrc`
- **Zsh**: adds line to `~/.zshrc` (or `$ZDOTDIR/.zshrc`)
- **Fish**: creates `~/.config/fish/functions/wt.fish` and `~/.config/fish/completions/wt.fish`
-- **Nushell** [experimental]: creates `$nu.default-config-dir/vendor/autoload/wt.nu` (typically `~/.config/nushell` on Linux, `~/Library/Application Support/nushell` on macOS)
+- **Nushell** [experimental]: creates `wt.nu` in Nushell's user vendor-autoload directory — the last entry of `$nu.vendor-autoload-dirs`, under `$nu.data-dir` (typically `~/.local/share/nushell/vendor/autoload` on Linux, `~/Library/Application Support/nushell/vendor/autoload` on macOS)
- **PowerShell** (Windows): creates both profile files if they don't exist:
- `Documents/PowerShell/Microsoft.PowerShell_profile.ps1` (PowerShell 7+)
- `Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1` (Windows PowerShell 5.1)
@@ -178,6 +178,7 @@ Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch
- `wt remove` — in addition to the target worktree, runs a background internal sweep: deletes `.git/wt/trash/` entries older than 24 hours (eventual cleanup for directories orphaned when a previous background removal was interrupted), and terminates (`SIGTERM` then `SIGKILL`) `git fsmonitor--daemon` processes whose worktree no longer exists
- `wt remove` — terminates the removed worktree's `git fsmonitor--daemon` process. Git starts this per-worktree filesystem-watch daemon when `core.fsmonitor=true`; once its worktree is gone it would leak. Removal sends `git fsmonitor--daemon stop`, then resolves that daemon's PID from its IPC socket and force-terminates it (SIGTERM, then SIGKILL) if it didn't exit. The signal only ever targets the daemon whose socket resolves to the worktree being removed.
- `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash)
+- `wt config shell install` — when migrating an integration to a new location, removes the worktrunk-managed file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`)
- `wt config shell uninstall` — removes shell integration from rc files
See [What files does Worktrunk create?](#what-files-does-worktrunk-create) for details.
diff --git a/skills/worktrunk/reference/shell-integration.md b/skills/worktrunk/reference/shell-integration.md
index bee3bfcd36..570b83da49 100644
--- a/skills/worktrunk/reference/shell-integration.md
+++ b/skills/worktrunk/reference/shell-integration.md
@@ -38,7 +38,7 @@ eval "$(wt config shell init zsh)"
wt config shell init fish | source
# nushell (experimental) — save to vendor autoload directory:
-wt config shell init nu | save -f ($nu.default-config-dir | path join vendor/autoload/wt.nu)
+wt config shell init nu | save -f ($nu.vendor-autoload-dirs | last | path join wt.nu)
# PowerShell ($PROFILE):
Invoke-Expression (& wt config shell init powershell | Out-String)
diff --git a/src/cli/config.rs b/src/cli/config.rs
index d17681c8ea..697c6a30c6 100644
--- a/src/cli/config.rs
+++ b/src/cli/config.rs
@@ -33,7 +33,7 @@ eval "$(wt config shell init zsh)"
Nushell [experimental] — save to vendor autoload directory:
```console
-$ wt config shell init nu | save -f ($nu.default-config-dir | path join vendor/autoload/wt.nu)
+$ wt config shell init nu | save -f ($nu.vendor-autoload-dirs | last | path join wt.nu)
```"#
)]
Init {
diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs
index 627c1cdf2d..d24359eadd 100644
--- a/src/commands/configure_shell.rs
+++ b/src/commands/configure_shell.rs
@@ -50,8 +50,12 @@ pub struct ScanResult {
pub skipped: Vec<(Shell, PathBuf)>, // Shell + first path that was checked
/// Zsh was configured but compinit is missing (completions won't work without it)
pub zsh_needs_compinit: bool,
- /// Legacy files that were cleaned up (e.g., fish conf.d/wt.fish -> functions/wt.fish migration)
- pub legacy_cleanups: Vec,
+ /// Legacy/stranded files cleaned up during install, paired with the shell
+ /// they belong to so the display can name the canonical replacement.
+ /// Covers the fish `conf.d/wt.fish` → `functions/wt.fish` migration (#566)
+ /// and the nushell `/vendor/autoload` → `/vendor/autoload`
+ /// move (#2878).
+ pub legacy_cleanups: Vec<(Shell, PathBuf)>,
}
pub struct CompletionResult {
@@ -120,14 +124,25 @@ fn is_worktrunk_managed_content(content: &str, cmd: &str) -> bool {
content.contains(&format!("{cmd} config shell init")) && content.contains("| source")
}
+/// Check if a Nushell wrapper file is worktrunk-managed.
+///
+/// The Nushell wrapper is a complete autoload file (not a `source` line), so it
+/// carries no `config shell init` marker. Every version worktrunk has shipped
+/// opens with this header comment, which a user's own `wt.nu` would not — so it
+/// is a safe signal that the file is ours to remove during stranded-file
+/// cleanup (issue #2878).
+fn is_worktrunk_managed_nushell(content: &str) -> bool {
+ content.contains("worktrunk shell integration for nushell")
+}
+
/// Clean up legacy fish conf.d file after installing to functions/
///
/// Previously, fish shell integration was installed to `~/.config/fish/conf.d/{cmd}.fish`.
/// This caused issues with Homebrew PATH setup (see issue #566). We now install to
/// `functions/{cmd}.fish` instead. This function removes the legacy file if it exists.
///
-/// Returns the paths of files that were cleaned up.
-fn cleanup_legacy_fish_conf_d(configured: &[ConfigureResult], cmd: &str) -> Vec {
+/// Returns the paths of files that were cleaned up, each paired with `Shell::Fish`.
+fn cleanup_legacy_fish_conf_d(configured: &[ConfigureResult], cmd: &str) -> Vec<(Shell, PathBuf)> {
let mut cleaned = Vec::new();
// Clean up if fish was part of the install (regardless of whether it already existed)
@@ -160,7 +175,7 @@ fn cleanup_legacy_fish_conf_d(configured: &[ConfigureResult], cmd: &str) -> Vec<
match fs::remove_file(&legacy_path) {
Ok(()) => {
- cleaned.push(legacy_path);
+ cleaned.push((Shell::Fish, legacy_path));
}
Err(e) => {
// Warn but don't fail - the new integration will still work
@@ -177,6 +192,59 @@ fn cleanup_legacy_fish_conf_d(configured: &[ConfigureResult], cmd: &str) -> Vec<
cleaned
}
+/// Clean up Nushell wrapper files stranded at legacy autoload locations.
+///
+/// Older worktrunk installed the wrapper under `/vendor/autoload`,
+/// which Nushell never autoloads (issue #2878). After installing to the correct
+/// vendor-autoload dir (`/vendor/autoload`), this removes any
+/// worktrunk-managed wrapper left at the other candidate paths so a stale,
+/// never-loaded copy isn't left behind.
+///
+/// Returns the paths removed, each paired with `Shell::Nushell`.
+fn cleanup_stranded_nushell(configured: &[ConfigureResult], cmd: &str) -> Vec<(Shell, PathBuf)> {
+ let mut cleaned = Vec::new();
+
+ // Only act if nushell was part of this install.
+ let Some(nu_result) = configured.iter().find(|r| r.shell == Shell::Nushell) else {
+ return cleaned;
+ };
+ // The canonical write target — never remove this one.
+ let canonical = &nu_result.path;
+
+ let Ok(candidates) = Shell::Nushell.config_paths(cmd) else {
+ return cleaned;
+ };
+
+ for path in candidates {
+ if &path == canonical || !path.exists() {
+ continue;
+ }
+ // Only remove files that are clearly worktrunk's, to avoid deleting a
+ // user's own `wt.nu`.
+ let Ok(content) = fs::read_to_string(&path) else {
+ continue;
+ };
+ if !is_worktrunk_managed_nushell(&content) {
+ continue;
+ }
+ match fs::remove_file(&path) {
+ Ok(()) => cleaned.push((Shell::Nushell, path)),
+ Err(e) => {
+ // Warn but don't fail - the new integration still works.
+ eprintln!(
+ "{}",
+ warning_message(color_print::cformat!(
+ "Failed to remove deprecated {}>: {e}",
+ format_path_for_display(&path)
+ ))
+ );
+ }
+ }
+ }
+
+ cleaned
+}
+
pub fn handle_configure_shell(
shell_filter: Option,
skip_confirmation: bool,
@@ -225,7 +293,8 @@ pub fn handle_configure_shell(
// If nothing needs to be changed, still clean up legacy fish conf.d files
// A user might have upgraded and have both functions/wt.fish and conf.d/wt.fish
if !needs_shell_changes && !needs_completion_changes {
- let legacy_cleanups = cleanup_legacy_fish_conf_d(&preview.configured, &cmd);
+ let mut legacy_cleanups = cleanup_legacy_fish_conf_d(&preview.configured, &cmd);
+ legacy_cleanups.extend(cleanup_stranded_nushell(&preview.configured, &cmd));
return Ok(ScanResult {
configured: preview.configured,
completion_results: completion_preview,
@@ -281,8 +350,10 @@ pub fn handle_configure_shell(
let zsh_needs_compinit = should_check_compinit && shell::detect_zsh_compinit() == Some(false);
// Clean up legacy fish conf.d file if we just installed to functions/
- // This handles migration from the old conf.d location (issue #566)
- let legacy_cleanups = cleanup_legacy_fish_conf_d(&result.configured, &cmd);
+ // (issue #566), plus any nushell wrapper stranded at a legacy autoload
+ // location (issue #2878).
+ let mut legacy_cleanups = cleanup_legacy_fish_conf_d(&result.configured, &cmd);
+ legacy_cleanups.extend(cleanup_stranded_nushell(&result.configured, &cmd));
Ok(ScanResult {
configured: result.configured,
@@ -382,7 +453,18 @@ pub fn scan_shell_configs(
let allow_create = shell_filter.is_some() || in_detected_shell;
if should_configure {
- let path = target_path.or_else(|| paths.first());
+ // Wrapper-based shells (Fish, Nushell) always write to the canonical
+ // location (`paths.first()`), never to whichever candidate happens to
+ // exist. For Nushell that matters: a wrapper stranded at a legacy
+ // `/vendor/autoload` path (issue #2878) must not become
+ // the write target — install writes the correct vendor-autoload path
+ // and `cleanup_stranded_nushell` removes the stale copy. Eval-based
+ // shells keep using the first existing config file.
+ let path = if shell.is_wrapper_based() {
+ paths.first()
+ } else {
+ target_path.or_else(|| paths.first())
+ };
if let Some(path) = path {
match configure_shell_file(shell, path, dry_run, allow_create, cmd) {
Ok(Some(result)) => results.push(result),
diff --git a/src/output/shell_integration.rs b/src/output/shell_integration.rs
index f0fabac47e..b735fdd3e9 100644
--- a/src/output/shell_integration.rs
+++ b/src/output/shell_integration.rs
@@ -311,16 +311,17 @@ pub fn print_shell_install_result(scan_result: &crate::commands::configure_shell
}
}
- // Show legacy file cleanups (migration from conf.d to functions)
- for legacy_path in &scan_result.legacy_cleanups {
+ // Show legacy file cleanups (fish conf.d → functions, nushell config-dir →
+ // data-dir vendor/autoload).
+ for (shell, legacy_path) in &scan_result.legacy_cleanups {
let old_path = format_path_for_display(legacy_path);
- // Find the new canonical path from the configured results
+ // Find the new canonical path for this shell from the configured results
let new_path = scan_result
.configured
.iter()
- .find(|r| r.shell == Shell::Fish)
+ .find(|r| r.shell == *shell)
.map(|r| format_path_for_display(&r.path))
- .unwrap_or_else(|| "~/.config/fish/functions/".to_string());
+ .unwrap_or_default();
eprintln!(
"{}",
info_message(cformat!(
diff --git a/src/shell/detection.rs b/src/shell/detection.rs
index d00f3ab9e7..292150c1d7 100644
--- a/src/shell/detection.rs
+++ b/src/shell/detection.rs
@@ -943,7 +943,7 @@ mod tests {
#[test]
fn test_nushell_save_pattern() {
// Nushell's config_line uses `save --force` (detected via "save" keyword)
- let line = "if (which wt | is-not-empty) { wt config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/wt.nu) }";
+ let line = "if (which wt | is-not-empty) { wt config shell init nu | save --force ($nu.vendor-autoload-dirs | last | path join wt.nu) }";
assert_detects(line, "wt", "nushell save pattern (actual config line)");
}
diff --git a/src/shell/mod.rs b/src/shell/mod.rs
index 73dca1f843..a850e13649 100644
--- a/src/shell/mod.rs
+++ b/src/shell/mod.rs
@@ -160,7 +160,7 @@ impl Shell {
}
Self::Nushell => {
format!(
- "if (which {cmd} | is-not-empty) {{ {cmd} config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/{cmd}.nu) }}",
+ "if (which {cmd} | is-not-empty) {{ {cmd} config shell init nu | save --force ($nu.vendor-autoload-dirs | last | path join {cmd}.nu) }}",
)
}
Self::PowerShell => {
diff --git a/src/shell/paths.rs b/src/shell/paths.rs
index 91877b005f..0cb0aa80f8 100644
--- a/src/shell/paths.rs
+++ b/src/shell/paths.rs
@@ -19,105 +19,166 @@ pub fn home_dir_required() -> Result {
})
}
-/// Parse the stdout of `nu -c "echo $nu.default-config-dir"` into a path.
+/// Test override pinning the Nushell vendor-autoload directory.
///
-/// Returns `Some(path)` if stdout contains a non-empty trimmed path, `None` otherwise.
-fn parse_nu_config_output(stdout: &[u8]) -> Option {
- let path_str = std::str::from_utf8(stdout).ok()?;
- let path = PathBuf::from(path_str.trim());
+/// Set by integration tests so the install target is deterministic across
+/// platforms (and independent of whether `nu` is on PATH). Mirrors the
+/// `WORKTRUNK_TEST_*` overrides consulted by `Shell::is_installed`.
+const TEST_NU_VENDOR_AUTOLOAD_ENV: &str = "WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR";
+
+/// The Nushell directories worktrunk resolves from `nu`, queried at most once
+/// per process.
+///
+/// Nushell autoloads `*.nu` files from `$nu.vendor-autoload-dirs`; the last
+/// entry is the user-writable one (under `$nu.data-dir`) and is worktrunk's
+/// install target. `$nu.default-config-dir` is kept only to locate files
+/// stranded by older worktrunk versions, which installed under
+/// `/vendor/autoload` — a path Nushell never autoloads
+/// (issue #2878).
+#[derive(Clone, Default)]
+struct NuDirs {
+ /// `$nu.vendor-autoload-dirs | last`. `None` when `nu` can't be queried.
+ vendor_autoload: Option,
+ /// `$nu.default-config-dir` (legacy install root). `None` when unavailable.
+ default_config: Option,
+}
+
+/// Parse a single trimmed path line from `nu` stdout.
+///
+/// Returns `None` for empty / whitespace-only input.
+fn parse_nu_path(line: &str) -> Option {
+ let path = PathBuf::from(line.trim());
(!path.as_os_str().is_empty()).then_some(path)
}
-/// Query `nu` for its default config directory, spawning `nu` at most once per process.
+/// Query `nu` for the directories worktrunk cares about, spawning `nu` at most
+/// once per process (memoised).
///
-/// Returns `Some(path)` if the `nu` binary is in PATH and reports its config dir,
-/// `None` otherwise (not installed, PATH issues, timeout, etc.). The result is
-/// memoised: a single `wt config shell install` resolves both the write path
-/// (`nushell_config_dir`) and the installed-state candidates
-/// (`nushell_config_candidates`), which would otherwise each spawn `nu` for the
-/// same answer.
-fn query_nu_config_dir() -> Option {
- static CACHE: OnceLock