Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/content/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** <span class="badge-experimental"></span>: creates `$nu.default-config-dir/vendor/autoload/wt.nu` (typically `~/.config/nushell` on Linux, `~/Library/Application Support/nushell` on macOS)
- **Nushell** <span class="badge-experimental"></span>: 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)
Expand Down Expand Up @@ -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 `<config-dir>/vendor/autoload` (now `<data-dir>/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.
Expand Down
3 changes: 2 additions & 1 deletion skills/worktrunk/reference/faq.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/shell-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
100 changes: 91 additions & 9 deletions src/commands/configure_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// 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 `<config-dir>/vendor/autoload` → `<data-dir>/vendor/autoload`
/// move (#2878).
pub legacy_cleanups: Vec<(Shell, PathBuf)>,
}

pub struct CompletionResult {
Expand Down Expand Up @@ -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<PathBuf> {
/// 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)
Expand Down Expand Up @@ -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
Expand All @@ -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 `<config-dir>/vendor/autoload`,
/// which Nushell never autoloads (issue #2878). After installing to the correct
/// vendor-autoload dir (`<data-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 <bold>{}</>: {e}",
format_path_for_display(&path)
))
);
}
}
}

cleaned
}

pub fn handle_configure_shell(
shell_filter: Option<Shell>,
skip_confirmation: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
// `<config-dir>/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),
Expand Down
11 changes: 6 additions & 5 deletions src/output/shell_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion src/shell/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
}

Expand Down
2 changes: 1 addition & 1 deletion src/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading
Loading