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> = OnceLock::new(); +/// Returns [`NuDirs::default`] (all `None`) when `nu` is not in PATH or the +/// query fails. A single `wt config shell install` resolves both the write +/// target (`completion_path`) and the installed-state candidates +/// (`config_paths`), which would otherwise each spawn `nu` for the same answer. +fn nu_dirs() -> NuDirs { + static CACHE: OnceLock = OnceLock::new(); CACHE .get_or_init(|| { - let output = crate::shell_exec::Cmd::new("nu") - .args(["-c", "echo $nu.default-config-dir"]) + // Test override: skip spawning `nu` for deterministic, offline tests. + if let Some(dir) = std::env::var_os(TEST_NU_VENDOR_AUTOLOAD_ENV) { + return NuDirs { + vendor_autoload: parse_nu_path(&dir.to_string_lossy()), + default_config: None, + }; + } + + let Some(output) = crate::shell_exec::Cmd::new("nu") + .args([ + "-c", + "print ($nu.vendor-autoload-dirs | last); print $nu.default-config-dir", + ]) .run() .ok() - .filter(|o| o.status.success())?; - parse_nu_config_output(&output.stdout) + .filter(|o| o.status.success()) + else { + return NuDirs::default(); + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + NuDirs { + vendor_autoload: lines.next().and_then(parse_nu_path), + default_config: lines.next().and_then(parse_nu_path), + } }) .clone() } -/// Resolve the nushell config directory from a queried path or platform defaults. +/// Fallback for Nushell's `$nu.data-dir` when `nu` can't be queried. /// -/// If `queried` is `Some`, uses that directly. Otherwise falls back to etcetera's -/// platform config dir, then `home/.config`. -fn resolve_nushell_config_dir(home: &std::path::Path, queried: Option) -> PathBuf { - queried.unwrap_or_else(|| { - choose_base_strategy() - .map(|s| s.config_dir()) - .unwrap_or_else(|_| home.join(".config")) - .join("nushell") - }) +/// Mirrors `nu_path::data_dir`: `XDG_DATA_HOME` (when absolute) wins on every +/// platform, otherwise `dirs::data_dir()` (`~/Library/Application Support` on +/// macOS, `%APPDATA%` on Windows, `~/.local/share` on Linux). Nushell appends +/// `nushell`. +fn nushell_data_dir_fallback(home: &std::path::Path) -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + let path = PathBuf::from(xdg); + if path.is_absolute() { + return path.join("nushell"); + } + } + dirs::data_dir() + .unwrap_or_else(|| home.join(".local").join("share")) + .join("nushell") } -/// Get Nushell's default config directory (single best path for writing). +/// The Nushell vendor-autoload directory worktrunk writes its wrapper to. /// -/// Used by `completion_path()` to determine where to write completions. -/// Queries `nu` for `$nu.default-config-dir` to handle platform-specific paths -/// — on macOS without `XDG_CONFIG_HOME` set, Nushell defaults to -/// `~/Library/Application Support/nushell`, otherwise it follows -/// `$XDG_CONFIG_HOME/nushell`. -/// Falls back to etcetera's `config_dir` (which uses XDG on every Unix -/// platform, so `~/.config/nushell` by default) if the nu command fails. -fn nushell_config_dir(home: &std::path::Path) -> PathBuf { - resolve_nushell_config_dir(home, query_nu_config_dir()) +/// Prefers the path `nu` reports (`$nu.vendor-autoload-dirs | last`); otherwise +/// reconstructs `/vendor/autoload`. +fn nushell_vendor_autoload_dir( + home: &std::path::Path, + queried: Option<&std::path::Path>, +) -> PathBuf { + match queried { + Some(dir) => dir.to_path_buf(), + None => nushell_data_dir_fallback(home) + .join("vendor") + .join("autoload"), + } } -/// Get candidate nushell config directories for checking if integration is installed. +/// Legacy `/vendor/autoload` directories where older worktrunk +/// versions wrongly installed the Nushell wrapper (issue #2878). /// -/// Returns multiple paths to check because: -/// - Installation might use the path from `nu -c "echo $nu.default-config-dir"` -/// - Runtime detection might fail the `nu` command (PATH issues, timeout, etc.) -/// - We need to find the config file regardless of which path was used -/// -/// Returns paths in priority order: queried path first, then fallbacks. -/// When the `nu` query succeeds, `first()` is the queried path — the same path -/// `nushell_config_dir()` writes to. (Their fallbacks differ when the query -/// fails: `nushell_config_dir()` prefers the platform config dir, this prefers -/// `$XDG_CONFIG_HOME`/`~/.config`.) -fn nushell_config_candidates(home: &std::path::Path) -> Vec { - let mut candidates = vec![]; - - // Best path: query nu directly (same source of truth as nushell_config_dir) - if let Some(queried) = query_nu_config_dir() { - candidates.push(queried); +/// Returned so install/uninstall can find and remove files stranded there. +/// Mirrors the candidate set the buggy code wrote to: the queried +/// `$nu.default-config-dir`, then `$XDG_CONFIG_HOME`, `~/.config`, and +/// etcetera's base config dir — each under `nushell/vendor/autoload`. +fn legacy_nushell_autoload_dirs( + home: &std::path::Path, + default_config: Option<&std::path::Path>, +) -> Vec { + let mut dirs: Vec = Vec::new(); + if let Some(dir) = default_config { + dirs.push(dir.to_path_buf()); } - - // Fallbacks for when nu query fails at runtime but succeeded during install: - - // XDG_CONFIG_HOME/nushell if set if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { - candidates.push(PathBuf::from(xdg_config).join("nushell")); + dirs.push(PathBuf::from(xdg_config).join("nushell")); } - - // ~/.config/nushell (XDG default) - candidates.push(home.join(".config").join("nushell")); - - // Platform config dir via etcetera. `choose_base_strategy` uses the XDG - // strategy on every Unix platform (including macOS), so this resolves to - // `$XDG_CONFIG_HOME/nushell` (default `~/.config/nushell`) on Linux and - // macOS, and `%APPDATA%\nushell` on Windows. + dirs.push(home.join(".config").join("nushell")); if let Ok(strategy) = choose_base_strategy() { - candidates.push(strategy.config_dir().join("nushell")); + dirs.push(strategy.config_dir().join("nushell")); } + dirs.into_iter() + .map(|d| d.join("vendor").join("autoload")) + .collect() +} - // Deduplicate while preserving priority order (queried path first) +/// Nushell autoload directories to check for an installed wrapper, in priority +/// order. +/// +/// The first entry is the canonical write target (the current vendor-autoload +/// dir); the rest are the data-dir fallback and the legacy config-dir locations +/// kept so install/uninstall can clean up stranded files. +fn nushell_autoload_candidates(home: &std::path::Path) -> Vec { + let dirs = nu_dirs(); + let mut candidates = vec![nushell_vendor_autoload_dir( + home, + dirs.vendor_autoload.as_deref(), + )]; + // New-style fallback, in case `nu` was queryable at install time but not now. + candidates.push( + nushell_data_dir_fallback(home) + .join("vendor") + .join("autoload"), + ); + candidates.extend(legacy_nushell_autoload_dirs( + home, + dirs.default_config.as_deref(), + )); + + // Deduplicate while preserving priority order (write target first). let mut seen = std::collections::HashSet::new(); candidates.retain(|p| seen.insert(p.clone())); - candidates } @@ -179,18 +240,14 @@ pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std: ] } super::Shell::Nushell => { - // Nushell vendor autoload directory - check multiple candidate locations because: - // - Installation might use the path from `nu -c "echo $nu.default-config-dir"` - // - Runtime detection might fail the `nu` command (PATH issues, timeout, etc.) - // - We need to find the config file regardless of which path was used during install - nushell_config_candidates(&home) + // Nushell autoloads `*.nu` from `$nu.vendor-autoload-dirs`; the last + // entry (under `$nu.data-dir`) is the write target. Earlier entries + // are fallbacks plus the legacy `/vendor/autoload` + // locations older worktrunk wrote to (never autoloaded — issue + // #2878), kept so install/uninstall can clean them up. + nushell_autoload_candidates(&home) .into_iter() - .map(|config_dir| { - config_dir - .join("vendor") - .join("autoload") - .join(format!("{}.nu", cmd)) - }) + .map(|autoload_dir| autoload_dir.join(format!("{}.nu", cmd))) .collect() } super::Shell::PowerShell => powershell_profile_paths(&home), @@ -250,12 +307,10 @@ pub fn completion_path(shell: super::Shell, cmd: &str) -> Result { - // Nushell completions are defined inline in the init script - // Return a path in the vendor autoload directory (same as config) - let config_dir = nushell_config_dir(&home); - config_dir - .join("vendor") - .join("autoload") + // Nushell completions are defined inline in the init script. + // Return the canonical vendor-autoload path (same as config). + let dirs = nu_dirs(); + nushell_vendor_autoload_dir(&home, dirs.vendor_autoload.as_deref()) .join(format!("{}.nu", cmd)) } super::Shell::PowerShell => { @@ -272,102 +327,91 @@ mod tests { use super::*; #[test] - fn test_parse_nu_config_output() { + fn test_parse_nu_path() { assert_eq!( - parse_nu_config_output(b"/home/user/.config/nushell\n"), - Some(PathBuf::from("/home/user/.config/nushell")) + parse_nu_path("/home/user/.local/share/nushell/vendor/autoload\n"), + Some(PathBuf::from( + "/home/user/.local/share/nushell/vendor/autoload" + )) ); - // Trims whitespace - assert_eq!( - parse_nu_config_output(b" /home/user/.config/nushell \n"), - Some(PathBuf::from("/home/user/.config/nushell")) - ); - // Empty / whitespace-only / invalid UTF-8 - assert_eq!(parse_nu_config_output(b""), None); - assert_eq!(parse_nu_config_output(b" \n"), None); - assert_eq!(parse_nu_config_output(&[0xFF, 0xFE]), None); + // Trims surrounding whitespace + assert_eq!(parse_nu_path(" /a/b "), Some(PathBuf::from("/a/b"))); + // Empty / whitespace-only + assert_eq!(parse_nu_path(""), None); + assert_eq!(parse_nu_path(" \n"), None); } #[test] - fn test_nushell_config_candidates_includes_xdg_and_defaults() { + fn test_nushell_vendor_autoload_dir_prefers_queried() { let home = PathBuf::from("/home/user"); - - let candidates = nushell_config_candidates(&home); - - // Should include default XDG path - assert!( - candidates - .iter() - .any(|p| p == &home.join(".config").join("nushell")), - "Should include ~/.config/nushell in candidates" + let queried = PathBuf::from("/opt/nu/vendor/autoload"); + assert_eq!( + nushell_vendor_autoload_dir(&home, Some(&queried)), + queried, + "the path nu reports should be used verbatim" ); + } - // Should include the platform config dir from etcetera - if let Ok(strategy) = choose_base_strategy() { - let platform_dir = strategy.config_dir().join("nushell"); - assert!( - candidates.iter().any(|p| p == &platform_dir), - "Should include platform config dir {platform_dir:?} in candidates" - ); - } - - // All candidates should be nushell config dirs + #[test] + fn test_nushell_vendor_autoload_dir_fallback_under_data_dir() { + let home = PathBuf::from("/home/user"); + let dir = nushell_vendor_autoload_dir(&home, None); + // Fallback must land under `/nushell/vendor/autoload`, never + // under the *config* dir (the bug this fixes). assert!( - candidates.iter().all(|p| p.ends_with("nushell")), - "All candidates should end with 'nushell'" + dir.ends_with("nushell/vendor/autoload"), + "fallback should be under /nushell/vendor/autoload: {dir:?}" ); } #[test] - fn test_nushell_config_candidates_always_has_fallback() { + fn test_legacy_nushell_autoload_dirs_are_config_rooted() { let home = PathBuf::from("/home/user"); - let candidates = nushell_config_candidates(&home); + let default_config = PathBuf::from("/home/user/.config/nushell"); + let dirs = legacy_nushell_autoload_dirs(&home, Some(&default_config)); - // Even without `nu` in PATH, we should get at least one fallback + // Includes the queried default-config-dir location... assert!( - !candidates.is_empty(), - "Should return at least 1 candidate path, got: {candidates:?}" + dirs.contains(&default_config.join("vendor").join("autoload")), + "should include the queried default-config-dir: {dirs:?}" ); - - // On macOS/Windows, should have at least 2 (XDG default + platform path) - #[cfg(any(target_os = "macos", windows))] + // ...and the XDG default ~/.config/nushell location. + assert!( + dirs.contains(&home.join(".config/nushell/vendor/autoload")), + "should include ~/.config/nushell: {dirs:?}" + ); + // Every legacy dir is a vendor/autoload dir. assert!( - candidates.len() >= 2, - "Should have at least 2 candidates on this platform, got: {candidates:?}" + dirs.iter().all(|p| p.ends_with("vendor/autoload")), + "all legacy dirs should end with vendor/autoload: {dirs:?}" ); } #[test] - fn test_nushell_config_candidates_no_duplicates() { + fn test_nushell_autoload_candidates_write_target_first_and_unique() { let home = PathBuf::from("/home/user"); - let candidates = nushell_config_candidates(&home); + let candidates = nushell_autoload_candidates(&home); + assert!(!candidates.is_empty(), "must return at least one candidate"); + // The write target (first entry) is a vendor/autoload directory. + assert!( + candidates[0].ends_with("vendor/autoload"), + "write target should be a vendor/autoload dir: {:?}", + candidates[0] + ); + // No duplicates. let unique: std::collections::HashSet<_> = candidates.iter().collect(); assert_eq!( candidates.len(), unique.len(), - "Candidates should not contain duplicates: {candidates:?}" + "candidates must not contain duplicates: {candidates:?}" ); - } - - #[test] - fn test_resolve_nushell_config_dir_with_queried_path() { - let home = PathBuf::from("/home/user"); - let queried = PathBuf::from("/custom/nushell"); - assert_eq!( - resolve_nushell_config_dir(&home, Some(queried.clone())), - queried - ); - } - - #[test] - fn test_resolve_nushell_config_dir_without_queried_path() { - let home = PathBuf::from("/home/user"); - let result = resolve_nushell_config_dir(&home, None); - // Should fall back to a platform config dir ending in "nushell" + // The legacy ~/.config location is always present for cleanup. assert!( - result.ends_with("nushell"), - "Fallback should end with 'nushell': {result:?}" + candidates + .iter() + .any(|p| p == &home.join(".config/nushell/vendor/autoload")), + "legacy ~/.config/nushell location must be a candidate: {candidates:?}" ); } } diff --git a/src/shell/snapshots/worktrunk__shell__tests__config_line_nu.snap b/src/shell/snapshots/worktrunk__shell__tests__config_line_nu.snap index 4f4bd02422..bb228cb796 100644 --- a/src/shell/snapshots/worktrunk__shell__tests__config_line_nu.snap +++ b/src/shell/snapshots/worktrunk__shell__tests__config_line_nu.snap @@ -2,4 +2,4 @@ source: src/shell/mod.rs expression: "Shell::Nushell.config_line(\"wt\")" --- -if (which wt | is-not-empty) { wt config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/wt.nu) } +if (which wt | is-not-empty) { wt config shell init nu | save --force ($nu.vendor-autoload-dirs | last | path join wt.nu) } diff --git a/src/shell/snapshots/worktrunk__shell__tests__config_line_nu_custom.snap b/src/shell/snapshots/worktrunk__shell__tests__config_line_nu_custom.snap index b3e281bc31..d807d0ca43 100644 --- a/src/shell/snapshots/worktrunk__shell__tests__config_line_nu_custom.snap +++ b/src/shell/snapshots/worktrunk__shell__tests__config_line_nu_custom.snap @@ -2,4 +2,4 @@ source: src/shell/mod.rs expression: "Shell::Nushell.config_line(\"git-wt\")" --- -if (which git-wt | is-not-empty) { git-wt config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/git-wt.nu) } +if (which git-wt | is-not-empty) { git-wt config shell init nu | save --force ($nu.vendor-autoload-dirs | last | path join git-wt.nu) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3adc17fa06..5edfeda73b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -145,6 +145,25 @@ pub fn temp_home() -> TempDir { TempDir::new().unwrap() } +/// Canonicalize a `temp_home` for use as a base when building paths that +/// production code will compare against the exported HOME. +/// +/// `set_temp_home_env` exports HOME via `dunce::canonicalize`, so paths the +/// command later prints (e.g. the nushell vendor-autoload dir) only tilde- +/// shorten in `format_path_for_display` when they share that canonical prefix. +/// Two platform pitfalls make a bare `temp_home.path()` wrong: +/// +/// - **macOS**: the temp dir lives under `/var`, a symlink to `/private/var`. +/// Canonicalizing resolves it to match the exported HOME, so the printed path +/// shortens to `~/...` instead of the full temp path. +/// - **Windows**: `std::fs::canonicalize` returns a `\\?\` verbatim path, where +/// the forward slashes in a later `.join("a/b/c")` are treated as literal +/// filename characters rather than separators — breaking both the file write +/// and `.exists()`. `dunce::canonicalize` returns an ordinary path. +pub fn canonical_temp_home(temp_home: &TempDir) -> std::path::PathBuf { + dunce::canonicalize(temp_home.path()).unwrap() +} + /// Repo with remote tracking set up. /// /// Builds on the `repo` fixture, adding a "remote" for the default branch. @@ -556,6 +575,11 @@ pub fn add_standard_env_redactions(settings: &mut insta::Settings) { settings.add_redaction(".env.PWD", "[PWD]"); // Mock commands directory (temp path for mock gh/glab binaries) settings.add_redaction(".env.MOCK_CONFIG_DIR", "[MOCK_CONFIG_DIR]"); + // Nushell vendor-autoload override (temp path pinned by shell-integration tests) + settings.add_redaction( + ".env.WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", + "[TEST_NU_VENDOR_AUTOLOAD]", + ); // OpenCode config directory (platform-independent override for tests) settings.add_redaction(".env.OPENCODE_CONFIG_DIR", "[TEST_OPENCODE_CONFIG]"); // `wt config show --full` tests inject WORKTRUNK_TEST_LATEST_VERSION = the diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index 597a9ff1b5..cc394c85a1 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -1,6 +1,7 @@ use crate::common::{ - TestRepo, repo, set_temp_home_env, set_xdg_config_path, setup_home_snapshot_settings, - setup_snapshot_settings, setup_snapshot_settings_with_home, temp_home, wt_command, + TestRepo, canonical_temp_home, repo, set_temp_home_env, set_xdg_config_path, + setup_home_snapshot_settings, setup_snapshot_settings, setup_snapshot_settings_with_home, + temp_home, wt_command, }; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; @@ -948,8 +949,10 @@ fn test_config_show_nushell_outdated_wrapper(mut repo: TestRepo, temp_home: Temp fs::create_dir_all(&global_config_dir).unwrap(); fs::write(global_config_dir.join("config.toml"), "").unwrap(); - // Create nushell vendor/autoload directory with an outdated wt.nu - let autoload = temp_home.path().join(".config/nushell/vendor/autoload"); + // Create the nushell vendor-autoload directory with an outdated wt.nu. + // Pin the dir via the test override so the path is deterministic across + // platforms (and independent of whether `nu` is installed). + let autoload = canonical_temp_home(&temp_home).join(".local/share/nushell/vendor/autoload"); fs::create_dir_all(&autoload).unwrap(); fs::write( autoload.join("wt.nu"), @@ -965,6 +968,7 @@ fn test_config_show_nushell_outdated_wrapper(mut repo: TestRepo, temp_home: Temp cmd.arg("config").arg("show").current_dir(repo.root_path()); set_temp_home_env(&mut cmd, temp_home.path()); set_xdg_config_path(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); assert_cmd_snapshot!(cmd); }); diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index a4a236939c..c95f2277b7 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -1,6 +1,6 @@ use crate::common::{ - TestRepo, repo, set_temp_home_env, set_xdg_config_path, setup_home_snapshot_settings, - temp_home, wt_command, + TestRepo, canonical_temp_home, repo, set_temp_home_env, set_xdg_config_path, + setup_home_snapshot_settings, temp_home, wt_command, }; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; @@ -867,6 +867,13 @@ fn test_uninstall_shell(repo: TestRepo, temp_home: TempDir) { repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); cmd.env("SHELL", "/bin/zsh"); + // Pin the nushell vendor-autoload dir so the "not found" path is + // deterministic across platforms and independent of whether `nu` is on + // the runner's PATH. + cmd.env( + "WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", + canonical_temp_home(&temp_home).join(".local/share/nushell/vendor/autoload"), + ); cmd.arg("config") .arg("shell") .arg("uninstall") @@ -882,7 +889,7 @@ fn test_uninstall_shell(repo: TestRepo, temp_home: TempDir) { ✓ Removed shell extension & completions for zsh @ ~/.zshrc ↳ No bash shell extension & completions in ~/.bashrc ↳ No fish shell extension in ~/.config/fish/functions/wt.fish - ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload/wt.nu + ↳ No nu shell extension & completions in ~/.local/share/nushell/vendor/autoload/wt.nu ↳ No fish completions in ~/.config/fish/completions/wt.fish ✓ Removed integration from 1 shell @@ -924,6 +931,13 @@ fn test_uninstall_shell_multiple(repo: TestRepo, temp_home: TempDir) { repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); cmd.env("SHELL", "/bin/zsh"); + // Pin the nushell vendor-autoload dir so the "not found" path is + // deterministic across platforms and independent of whether `nu` is on + // the runner's PATH. + cmd.env( + "WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", + canonical_temp_home(&temp_home).join(".local/share/nushell/vendor/autoload"), + ); cmd.arg("config") .arg("shell") .arg("uninstall") @@ -939,7 +953,7 @@ fn test_uninstall_shell_multiple(repo: TestRepo, temp_home: TempDir) { ✓ Removed shell extension & completions for bash @ ~/.bashrc ✓ Removed shell extension & completions for zsh @ ~/.zshrc ↳ No fish shell extension in ~/.config/fish/functions/wt.fish - ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload/wt.nu + ↳ No nu shell extension & completions in ~/.local/share/nushell/vendor/autoload/wt.nu ↳ No fish completions in ~/.config/fish/completions/wt.fish ✓ Removed integration from 2 shells @@ -1663,10 +1677,15 @@ fn test_uninstall_shell_dry_run_multiple(repo: TestRepo, temp_home: TempDir) { /// `shell extension & completions`. #[rstest] fn test_uninstall_shell_dry_run_nushell(repo: TestRepo, temp_home: TempDir) { + let autoload = temp_home + .path() + .join(".local/share/nushell/vendor/autoload"); + // Install nushell integration first so dry-run uninstall finds something. let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); install_cmd.env("SHELL", "/bin/nu"); install_cmd .args(["config", "shell", "install", "nu", "--yes"]) @@ -1681,6 +1700,7 @@ fn test_uninstall_shell_dry_run_nushell(repo: TestRepo, temp_home: TempDir) { let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/nu"); cmd.args(["config", "shell", "uninstall", "nu", "--dry-run"]) .current_dir(repo.root_path()); @@ -1850,16 +1870,20 @@ mod pty_tests { /// Test installing nushell shell integration /// -/// Runs `install nu --yes` and verifies the wrapper file was created. -/// This covers the nushell-specific wrapper generation path in configure_shell. -/// -/// set_temp_home_env sets XDG_CONFIG_HOME to home/.config, and the `nu` binary -/// isn't available in tests, so nushell_config_dir falls back to XDG_CONFIG_HOME/nushell. +/// Runs `install nu --yes` and verifies the wrapper file was created in the +/// vendor-autoload directory (issue #2878). Pins that directory via +/// `WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR` so the target is deterministic on +/// every platform (and doesn't depend on `nu` being installed). #[rstest] fn test_configure_shell_nushell(repo: TestRepo, temp_home: TempDir) { + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + let nu_config = autoload.join("wt.nu"); + let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/nu"); cmd.arg("config") .arg("shell") @@ -1882,18 +1906,9 @@ fn test_configure_shell_nushell(repo: TestRepo, temp_home: TempDir) { stderr ); - // set_temp_home_env sets XDG_CONFIG_HOME → home/.config, so the nushell - // vendor autoload path is deterministic (nu binary not available in tests). - let home = std::fs::canonicalize(temp_home.path()).unwrap(); - let nu_config = home - .join(".config") - .join("nushell") - .join("vendor") - .join("autoload") - .join("wt.nu"); assert!( nu_config.exists(), - "wt.nu should be created at {:?}", + "wt.nu should be created in the vendor-autoload dir at {:?}", nu_config ); @@ -1911,18 +1926,15 @@ fn test_configure_shell_nushell(repo: TestRepo, temp_home: TempDir) { /// This covers the nushell-specific uninstall block in configure_shell. #[rstest] fn test_uninstall_shell_nushell(repo: TestRepo, temp_home: TempDir) { - let home = std::fs::canonicalize(temp_home.path()).unwrap(); - let nu_config = home - .join(".config") - .join("nushell") - .join("vendor") - .join("autoload") - .join("wt.nu"); + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + let nu_config = autoload.join("wt.nu"); // First install to create the wrapper file let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); install_cmd.env("SHELL", "/bin/nu"); install_cmd .args(["config", "shell", "install", "nu", "--yes"]) @@ -1944,6 +1956,7 @@ fn test_uninstall_shell_nushell(repo: TestRepo, temp_home: TempDir) { let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/nu"); cmd.args(["config", "shell", "uninstall", "nu", "--yes"]) .current_dir(repo.root_path()); @@ -1970,20 +1983,22 @@ fn test_uninstall_shell_nushell(repo: TestRepo, temp_home: TempDir) { ); } -/// Test that nushell uninstall cleans up config files at all candidate locations. -/// -/// Exercises the fix where uninstall iterates all nushell config candidates, -/// not just the first. Simulates the scenario where the file was installed at -/// a location that is no longer the primary candidate (e.g., `nu` reported -/// a different path during install than what we'd pick now). +/// Test that nushell uninstall cleans up the wrapper at every candidate +/// location — the canonical vendor-autoload dir and the legacy +/// `/vendor/autoload` paths older worktrunk stranded files at +/// (issue #2878). `config_paths(Nushell)` returns all of them and uninstall +/// iterates the full list. #[rstest] fn test_uninstall_nushell_cleans_all_candidate_locations(repo: TestRepo, temp_home: TempDir) { - let home = std::fs::canonicalize(temp_home.path()).unwrap(); + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + let canonical = autoload.join("wt.nu"); - // Install nushell integration normally (goes to XDG_CONFIG_HOME/nushell) + // Install nushell integration to the canonical vendor-autoload dir. let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); install_cmd.env("SHELL", "/bin/nu"); install_cmd .args(["config", "shell", "install", "nu", "--yes"]) @@ -1995,38 +2010,21 @@ fn test_uninstall_nushell_cleans_all_candidate_locations(repo: TestRepo, temp_ho "Install should succeed:\nstderr: {}", String::from_utf8_lossy(&install_output.stderr) ); + assert!(canonical.exists(), "Canonical config should exist"); + + // Simulate a wrapper stranded by an older worktrunk at the legacy + // config-dir location (set_temp_home_env points XDG_CONFIG_HOME at + // ~/.config, so that's a legacy candidate). + let legacy_dir = home.join(".config/nushell/vendor/autoload"); + fs::create_dir_all(&legacy_dir).unwrap(); + let legacy_config = legacy_dir.join("wt.nu"); + fs::copy(&canonical, &legacy_config).unwrap(); - let primary_config = home - .join(".config") - .join("nushell") - .join("vendor") - .join("autoload") - .join("wt.nu"); - assert!(primary_config.exists(), "Primary config should exist"); - - // Copy the config to a secondary candidate location (~/.config is the XDG default, - // but also manually create one at a different path to simulate install at a - // non-primary location). Use a custom XDG_CONFIG_HOME to make a second candidate - // be the primary during uninstall. - let secondary_dir = home - .join("custom-config") - .join("nushell") - .join("vendor") - .join("autoload"); - fs::create_dir_all(&secondary_dir).unwrap(); - let secondary_config = secondary_dir.join("wt.nu"); - fs::copy(&primary_config, &secondary_config).unwrap(); - - // Uninstall with XDG_CONFIG_HOME pointing to the custom location. - // The custom path becomes the first candidate, but the original at ~/.config/nushell - // should also be cleaned up since uninstall checks all candidates. + // Uninstall should remove the wrapper at both the canonical and legacy paths. let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); - // Override XDG_CONFIG_HOME to point to custom dir, making it the primary candidate - cmd.env("HOME", &home); - cmd.env("USERPROFILE", &home); - cmd.env("XDG_CONFIG_HOME", home.join("custom-config")); - cmd.env("APPDATA", home.join("custom-config")); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/nu"); cmd.args(["config", "shell", "uninstall", "nu", "--yes"]) .current_dir(repo.root_path()); @@ -2040,12 +2038,12 @@ fn test_uninstall_nushell_cleans_all_candidate_locations(repo: TestRepo, temp_ho // Both locations should be cleaned up assert!( - !primary_config.exists(), - "Primary config at ~/.config/nushell should be deleted: {primary_config:?}" + !canonical.exists(), + "Canonical wrapper should be deleted: {canonical:?}" ); assert!( - !secondary_config.exists(), - "Secondary config at custom XDG path should be deleted: {secondary_config:?}" + !legacy_config.exists(), + "Stranded legacy wrapper should be deleted: {legacy_config:?}" ); } @@ -2114,11 +2112,15 @@ fn test_nushell_auto_detection_creates_vendor_autoload(repo: TestRepo, temp_home // Don't create vendor/autoload - the whole point is that it doesn't exist yet // but nushell IS detected on the system + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); // Force nushell detection via test env var (parallels WORKTRUNK_TEST_POWERSHELL_ENV) cmd.env("WORKTRUNK_TEST_NUSHELL_ENV", "1"); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/zsh"); cmd.arg("config") .arg("shell") @@ -2146,14 +2148,8 @@ fn test_nushell_auto_detection_creates_vendor_autoload(repo: TestRepo, temp_home stderr ); - // Verify the nushell wrapper was created with vendor/autoload/ directory - let home = std::fs::canonicalize(temp_home.path()).unwrap(); - let nu_config = home - .join(".config") - .join("nushell") - .join("vendor") - .join("autoload") - .join("wt.nu"); + // Verify the nushell wrapper was created in the vendor-autoload directory + let nu_config = autoload.join("wt.nu"); assert!( nu_config.exists(), "wt.nu should be created at {:?}", @@ -2176,10 +2172,15 @@ fn test_nushell_auto_detection_creates_vendor_autoload(repo: TestRepo, temp_home fn test_config_show_detects_nushell_integration(mut repo: TestRepo, temp_home: TempDir) { repo.setup_mock_ci_tools_unauthenticated(); + let autoload = temp_home + .path() + .join(".local/share/nushell/vendor/autoload"); + // Install nushell integration let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); install_cmd.env("SHELL", "/bin/nu"); install_cmd .args(["config", "shell", "install", "nu", "--yes"]) @@ -2196,6 +2197,7 @@ fn test_config_show_detects_nushell_integration(mut repo: TestRepo, temp_home: T repo.configure_wt_cmd(&mut cmd); repo.configure_mock_commands(&mut cmd); set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); cmd.env("SHELL", "/bin/nu"); cmd.args(["config", "show"]).current_dir(repo.root_path()); @@ -2206,3 +2208,156 @@ fn test_config_show_detects_nushell_integration(mut repo: TestRepo, temp_home: T "config show should detect nushell integration:\n{stdout}" ); } + +/// Installing nushell removes a wrapper stranded by older worktrunk at the +/// legacy `/vendor/autoload` location (issue #2878), and writes the +/// wrapper to the canonical vendor-autoload dir instead. Cross-platform: the +/// target dir is pinned via the test override so it doesn't depend on `nu`. +#[rstest] +fn test_nushell_install_cleans_stranded_legacy(repo: TestRepo, temp_home: TempDir) { + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + let canonical = autoload.join("wt.nu"); + + // A wrapper stranded by an older worktrunk at the legacy config-dir location + // (set_temp_home_env points XDG_CONFIG_HOME at ~/.config). The worktrunk + // header marks it as ours to remove. + let legacy_dir = home.join(".config/nushell/vendor/autoload"); + fs::create_dir_all(&legacy_dir).unwrap(); + let legacy = legacy_dir.join("wt.nu"); + fs::write( + &legacy, + "# worktrunk shell integration for nushell\ndef --wrapped wt [...args] { command wt-old ...$args }\n", + ) + .unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); + cmd.env("SHELL", "/bin/nu"); + cmd.args(["config", "shell", "install", "nu", "--yes"]) + .current_dir(repo.root_path()); + + let output = cmd.output().expect("Failed to execute install"); + assert!( + output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Wrapper written to the canonical vendor-autoload dir... + assert!( + canonical.exists(), + "wrapper should be written to the vendor-autoload dir: {canonical:?}" + ); + // ...and the stranded legacy copy removed. + assert!( + !legacy.exists(), + "stranded legacy wrapper should be removed: {legacy:?}" + ); + + // The cleanup is surfaced to the user. + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("deprecated"), + "install should report the stranded-file cleanup:\n{stderr}" + ); +} + +/// Data safety: install must NOT delete a `wt.nu` at a legacy location that +/// isn't worktrunk-managed (no worktrunk header) — it could be the user's own +/// file. Only files carrying the worktrunk header are cleaned up (issue #2878). +#[rstest] +fn test_nushell_install_keeps_unmanaged_legacy_file(repo: TestRepo, temp_home: TempDir) { + let home = canonical_temp_home(&temp_home); + let autoload = home.join(".local/share/nushell/vendor/autoload"); + + // A user-authored wt.nu at the legacy config-dir location — no worktrunk + // header, so it must be left untouched. + let legacy_dir = home.join(".config/nushell/vendor/autoload"); + fs::create_dir_all(&legacy_dir).unwrap(); + let legacy = legacy_dir.join("wt.nu"); + let user_content = "# my own wt helper\ndef wt [] { echo hi }\n"; + fs::write(&legacy, user_content).unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload); + cmd.env("SHELL", "/bin/nu"); + cmd.args(["config", "shell", "install", "nu", "--yes"]) + .current_dir(repo.root_path()); + + let output = cmd.output().expect("Failed to execute install"); + assert!( + output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Canonical wrapper written, user's legacy file preserved verbatim. + assert!(autoload.join("wt.nu").exists(), "canonical wrapper missing"); + assert!( + legacy.exists(), + "unmanaged legacy wt.nu must be preserved: {legacy:?}" + ); + assert_eq!( + fs::read_to_string(&legacy).unwrap(), + user_content, + "unmanaged legacy file must be left unchanged" + ); +} + +/// End-to-end guard for issue #2878 using the real `nu` binary (no override): +/// after `install nu`, the wrapper worktrunk wrote must live inside one of the +/// directories `nu` actually autoloads (`$nu.vendor-autoload-dirs`). This fails +/// against the old `/vendor/autoload` behavior — that path is in +/// neither autoload list — and passes once the write path follows +/// `$nu.vendor-autoload-dirs`. +/// +/// Gated on `shell-integration-tests` (CI installs `nu` on non-Windows runners). +#[cfg(all(unix, feature = "shell-integration-tests"))] +#[rstest] +fn test_nushell_install_target_is_a_vendor_autoload_dir(repo: TestRepo, temp_home: TempDir) { + let home = canonical_temp_home(&temp_home); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("SHELL", "/bin/nu"); + cmd.args(["config", "shell", "install", "nu", "--yes"]) + .current_dir(repo.root_path()); + let output = cmd.output().expect("Failed to execute install"); + assert!( + output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Ask the same `nu` (HOME isolated to temp_home) where it autoloads vendor + // files, then confirm worktrunk's wrapper landed in one of those dirs. + let listing = std::process::Command::new("nu") + .args(["-c", "$nu.vendor-autoload-dirs | str join (char newline)"]) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .output() + .expect("nu must be on PATH (installed by .github/actions/test-setup)"); + assert!( + listing.status.success(), + "querying nu vendor-autoload-dirs failed:\nstderr: {}", + String::from_utf8_lossy(&listing.stderr) + ); + let dirs = String::from_utf8(listing.stdout).unwrap(); + + let installed_in_autoload = dirs + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .any(|d| std::path::Path::new(d).join("wt.nu").exists()); + assert!( + installed_in_autoload, + "worktrunk must install wt.nu into one of nu's vendor-autoload dirs (issue #2878).\n\ + vendor-autoload-dirs:\n{dirs}" + ); +} diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_nushell_outdated_wrapper.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_nushell_outdated_wrapper.snap index c4cf0124d8..aa3d0f07b7 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_nushell_outdated_wrapper.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_nushell_outdated_wrapper.snap @@ -29,7 +29,6 @@ info: OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" PATH: "[PATH]" PSModulePath: "" - RUST_LOG: warn SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" @@ -44,6 +43,7 @@ info: WORKTRUNK_TEST_FISH_INSTALLED: "0" WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR: "[TEST_NU_VENDOR_AUTOLOAD]" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" @@ -65,7 +65,7 @@ exit_code: 0 ▲ Shell integration not active   Invoked as: [PROJECT_ROOT]/target/[BUILD_MODE]/wt -▲ nu: Outdated shell extension @ ~/.config/nushell/vendor/autoload/wt.nu +▲ nu: Outdated shell extension @ ~/.local/share/nushell/vendor/autoload/wt.nu ↳ To update, run wt config shell install nu OTHER