From 4d60e680743911a4f3260b42c19fad826836e687 Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 17:17:17 +0100 Subject: [PATCH 1/6] feat: Add `wt cd` command and `wt init` for native shell integration Add `wt cd` to navigate into worktree directories via subshell, and `wt init` to output a shell wrapper function that enables native `cd` without spawning a subshell. wt cd: - Resolve worktree by branch name, filesystem path, or interactive picker - Spawn a subshell in the target directory (fallback for users without shell integration) - --print flag outputs the resolved path to stdout for scripting/shell wrappers - Proper signal forwarding and exit code propagation - All user-facing output goes to stderr; stdout reserved for machine-readable output wt init: - Outputs a shell wrapper function for zsh, bash, or fish - Auto-detects shell from $SHELL when no argument given - Wrapper intercepts `wt cd`, uses --print + builtin cd for native directory change - Falls back to subshell-based `wt cd` if --print fails - Detects if already installed in rc file - Shows copyable commands to add to rc file and reload shell Also improves wt open: - Route all errors to stderr consistently (process.stderr.write) - Narrow catch blocks to ENOENT only - Output raw path when editor is set to "none" - Add reject:false to git rev-parse for graceful error handling Powered by human calories and mass GPU cycles. --- README.md | 40 ++++ build/commands/cd.js | 118 +++++++++++ build/commands/init.js | 104 ++++++++++ build/commands/open.js | 90 +++++---- build/index.js | 13 ++ build/utils/tui.js | 10 +- src/commands/cd.ts | 122 ++++++++++++ src/commands/init.ts | 122 ++++++++++++ src/commands/open.ts | 88 +++++---- src/index.ts | 15 ++ src/utils/tui.ts | 14 +- test/cd.test.ts | 440 +++++++++++++++++++++++++++++++++++++++++ test/init.test.ts | 241 ++++++++++++++++++++++ 13 files changed, 1326 insertions(+), 91 deletions(-) create mode 100644 build/commands/cd.js create mode 100644 build/commands/init.js create mode 100644 src/commands/cd.ts create mode 100644 src/commands/init.ts create mode 100644 test/cd.test.ts create mode 100644 test/init.test.ts diff --git a/README.md b/README.md index 7fdb921..545cb67 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ wt open [pathOrBranch] **Interactive Selection**: Run `wt open` without arguments to see a fuzzy-searchable list of worktrees. +Options: +- `-e, --editor `: Editor to use for opening the worktree (overrides default editor) + +When the editor is set to `none` (via `wt config set editor none` or `-e none`), `wt open` prints **only the worktree path** to stdout (all UI/error output remains on stderr) instead of launching an editor. Note that this differs from `wt cd`, which opens a subshell in the worktree directory. + Example: ```bash wt open # Interactive selection @@ -118,6 +123,41 @@ wt open feature/login # Open by branch name wt open ./path/to/worktree # Open by path ``` +### CD into a worktree + +```bash +wt cd [pathOrBranch] +``` + +Opens a new shell session inside the selected worktree directory. Type `exit` to return to your previous directory (on Unix, `Ctrl+D` also works; on Windows `cmd.exe`, use `Ctrl+Z` then Enter). + +Options: +- `--print`: Print the resolved path to stdout instead of spawning a subshell + +Example: +```bash +wt cd # Interactive selection, opens shell in worktree +wt cd feature/login # Open shell in branch's worktree +wt cd feature/login --print # Print path only (for scripting) +``` + +### Shell Integration + +For native `cd` support (changes your current shell directory instead of spawning a subshell): + +```bash +# Auto-detect shell and show setup instructions: +wt init + +# Or specify explicitly — add to your .zshrc or .bashrc: +eval "$(wt init zsh)" # or bash + +# For fish, add to config.fish: +wt init fish | source +``` + +After setup, `wt cd` will change your current shell's directory directly via a shell wrapper function. + ### List worktrees ```bash diff --git a/build/commands/cd.js b/build/commands/cd.js new file mode 100644 index 0000000..5deb06f --- /dev/null +++ b/build/commands/cd.js @@ -0,0 +1,118 @@ +import { execa } from "execa"; +import chalk from "chalk"; +import { stat } from "node:fs/promises"; +import { constants } from "node:os"; +import { resolve } from "node:path"; +import { findWorktreeByBranch, findWorktreeByPath } from "../utils/git.js"; +import { selectWorktree } from "../utils/tui.js"; +function isEnoent(err) { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} +export async function cdWorktreeHandler(pathOrBranch = "", options = {}) { + try { + const gitCheck = await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + if (gitCheck.exitCode !== 0 || gitCheck.stdout.trim() !== "true") { + process.stderr.write(chalk.red("Not inside a git work tree.") + "\n"); + process.exit(1); + } + let targetWorktree = null; + if (!pathOrBranch) { + const selected = await selectWorktree({ + message: "Select a worktree to cd into", + excludeMain: false, + ...(options.print ? { stdout: process.stderr } : {}), + }); + if (!selected || Array.isArray(selected)) { + process.stderr.write(chalk.yellow("No worktree selected.") + "\n"); + process.exit(0); + } + targetWorktree = selected; + } + else { + // Check if argument is an existing filesystem path + let pathStats = null; + try { + pathStats = await stat(pathOrBranch); + } + catch (err) { + if (!isEnoent(err)) + throw err; + // ENOENT: not a valid path, will try as branch name below + } + if (pathStats) { + if (pathStats.isDirectory()) { + targetWorktree = await findWorktreeByPath(pathOrBranch); + if (!targetWorktree) { + try { + await stat(resolve(pathOrBranch, ".git")); + targetWorktree = { + path: resolve(pathOrBranch), + head: '', + branch: null, + detached: false, + locked: false, + prunable: false, + isMain: false, + bare: false, + }; + } + catch { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); + process.exit(1); + } + } + } + else { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" is not a directory.`) + "\n"); + process.exit(1); + } + } + if (!targetWorktree) { + targetWorktree = await findWorktreeByBranch(pathOrBranch); + if (!targetWorktree) { + process.stderr.write(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`) + "\n"); + process.stderr.write(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt cd' without arguments to select interactively.") + "\n"); + process.exit(1); + } + } + } + const targetPath = targetWorktree.path; + try { + await stat(targetPath); + } + catch (err) { + if (!isEnoent(err)) + throw err; + process.stderr.write(chalk.red(`The worktree path "${targetPath}" no longer exists.`) + "\n"); + process.stderr.write(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.") + "\n"); + process.exit(1); + } + if (options.print) { + process.stdout.write(targetPath + "\n"); + return; + } + // Spawn a subshell in the target directory so cd works without shell config + const shell = process.platform === "win32" + ? process.env.COMSPEC || "cmd.exe" + : process.env.SHELL || "/bin/sh"; + process.stderr.write(chalk.green(`Entering ${targetPath}`) + "\n"); + process.stderr.write(chalk.dim(`(exit or ctrl+d to return)`) + "\n"); + const result = await execa(shell, [], { + cwd: targetPath, + stdio: "inherit", + reject: false, + }); + if (result.signal) { + const signum = constants.signals[result.signal] ?? 0; + process.exit(128 + signum); + } + if (result.exitCode != null && result.exitCode !== 0) { + process.exit(result.exitCode); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(chalk.red("Failed to resolve worktree: ") + message + "\n"); + process.exit(1); + } +} diff --git a/build/commands/init.js b/build/commands/init.js new file mode 100644 index 0000000..b7f2318 --- /dev/null +++ b/build/commands/init.js @@ -0,0 +1,104 @@ +import chalk from "chalk"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +// Shell wrapper functions that intercept `wt cd` to perform a native directory +// change via `--print`. If `--print` exits non-zero (e.g. no worktree found), +// falls back to the normal subshell-based `wt cd`. +const BASH_ZSH_FUNCTION = `wt() { + if [[ "\$1" == "cd" ]]; then + shift + local dir + dir=$(command wt cd --print "\$@") + if [[ \$? -eq 0 && -n "\$dir" ]]; then + builtin cd "\$dir" + else + command wt cd "\$@" + fi + else + command wt "\$@" + fi +}`; +const FISH_FUNCTION = `function wt + if test (count $argv) -gt 0 -a "$argv[1]" = "cd" + set -l dir (command wt cd --print $argv[2..-1]) + if test $status -eq 0 -a -n "$dir" + builtin cd $dir + else + command wt cd $argv[2..-1] + end + else + command wt $argv + end +end`; +const SUPPORTED_SHELLS = ["zsh", "bash", "fish"]; +const SHELL_RC = { + zsh: { file: "~/.zshrc", line: 'eval "$(wt init zsh)"' }, + bash: { file: "~/.bashrc", line: 'eval "$(wt init bash)"' }, + fish: { file: "~/.config/fish/config.fish", line: "wt init fish | source" }, +}; +export function getShellFunction(shell) { + if (shell === "fish") + return FISH_FUNCTION; + return BASH_ZSH_FUNCTION; +} +export function detectShell() { + const shellEnv = process.env.SHELL || ""; + const basename = shellEnv.split("/").pop()?.toLowerCase() || ""; + if (SUPPORTED_SHELLS.includes(basename)) + return basename; + return null; +} +function expandTilde(path) { + return path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path; +} +function isAlreadyInstalled(rc) { + try { + const content = readFileSync(expandTilde(rc.file), "utf-8"); + return content.includes(rc.line); + } + catch { + return false; + } +} +const SHELL_SOURCE = { + zsh: "source ~/.zshrc", + bash: "source ~/.bashrc", + fish: "source ~/.config/fish/config.fish", +}; +export function initHandler(shell) { + let resolved; + if (shell) { + const normalized = shell.toLowerCase(); + if (!SUPPORTED_SHELLS.includes(normalized)) { + process.stderr.write(chalk.red(`Unsupported shell: "${shell}". Supported shells: ${SUPPORTED_SHELLS.join(", ")}`) + "\n"); + process.exit(1); + } + resolved = normalized; + } + else { + const detected = detectShell(); + if (!detected) { + process.stderr.write(chalk.red("Could not detect shell from $SHELL.") + "\n"); + process.stderr.write(chalk.yellow(`Usage: wt init <${SUPPORTED_SHELLS.join("|")}>`) + "\n"); + process.exit(1); + } + resolved = detected; + process.stderr.write(chalk.dim(`Detected shell: ${resolved}`) + "\n"); + } + process.stdout.write(getShellFunction(resolved) + "\n"); + const rc = SHELL_RC[resolved]; + if (isAlreadyInstalled(rc)) { + process.stderr.write(chalk.green(`Already installed in ${rc.file}`) + "\n"); + return; + } + process.stderr.write(chalk.dim(`# Add to ${rc.file}:`) + "\n"); + process.stderr.write(chalk.dim("# " + rc.line) + "\n"); + process.stderr.write("\n"); + process.stderr.write(chalk.dim("# Run this to add it automatically:") + "\n"); + const appendCmd = `echo '${rc.line}' >> ${rc.file}`; + process.stderr.write(" " + chalk.cyan(appendCmd) + "\n"); + process.stderr.write("\n"); + process.stderr.write(chalk.dim("# Then reload your shell:") + "\n"); + process.stderr.write(" " + chalk.cyan(SHELL_SOURCE[resolved]) + "\n"); +} diff --git a/build/commands/open.js b/build/commands/open.js index b6b5a1f..bdf3597 100644 --- a/build/commands/open.js +++ b/build/commands/open.js @@ -5,10 +5,13 @@ import { resolve } from "node:path"; import { getDefaultEditor, shouldSkipEditor } from "../config.js"; import { findWorktreeByBranch, findWorktreeByPath } from "../utils/git.js"; import { selectWorktree } from "../utils/tui.js"; +function isEnoent(err) { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} export async function openWorktreeHandler(pathOrBranch = "", options) { try { // 1. Validate we're in a git repo - await execa("git", ["rev-parse", "--is-inside-work-tree"]); + await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); let targetWorktree = null; // Improvement #4: Interactive TUI for missing arguments if (!pathOrBranch) { @@ -17,23 +20,28 @@ export async function openWorktreeHandler(pathOrBranch = "", options) { excludeMain: false, }); if (!selected || Array.isArray(selected)) { - console.log(chalk.yellow("No worktree selected.")); + process.stderr.write(chalk.yellow("No worktree selected.") + "\n"); process.exit(0); } targetWorktree = selected; } else { // Try to find by path first + let pathStats = null; try { - const stats = await stat(pathOrBranch); - if (stats.isDirectory()) { + pathStats = await stat(pathOrBranch); + } + catch (err) { + if (!isEnoent(err)) + throw err; + // ENOENT: not a valid path, will try as branch name below + } + if (pathStats) { + if (pathStats.isDirectory()) { targetWorktree = await findWorktreeByPath(pathOrBranch); if (!targetWorktree) { - // It's a directory but not a registered worktree - // Still try to open it if it has .git try { await stat(resolve(pathOrBranch, ".git")); - // It's a git worktree, create a minimal info object targetWorktree = { path: resolve(pathOrBranch), head: '', @@ -46,21 +54,22 @@ export async function openWorktreeHandler(pathOrBranch = "", options) { }; } catch { - console.error(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`)); + process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); process.exit(1); } } } - } - catch { - // Not a valid path, try as branch name + else { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" is not a directory.`) + "\n"); + process.exit(1); + } } // If not found by path, try by branch name if (!targetWorktree) { targetWorktree = await findWorktreeByBranch(pathOrBranch); if (!targetWorktree) { - console.error(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`)); - console.error(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt open' without arguments to select interactively.")); + process.stderr.write(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`) + "\n"); + process.stderr.write(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt open' without arguments to select interactively.") + "\n"); process.exit(1); } } @@ -70,54 +79,51 @@ export async function openWorktreeHandler(pathOrBranch = "", options) { try { await stat(targetPath); } - catch { - console.error(chalk.red(`The worktree path "${targetPath}" no longer exists.`)); - console.error(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.")); + catch (err) { + if (!isEnoent(err)) + throw err; + process.stderr.write(chalk.red(`The worktree path "${targetPath}" no longer exists.`) + "\n"); + process.stderr.write(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.") + "\n"); process.exit(1); } - // Display worktree info - if (targetWorktree.branch) { - console.log(chalk.blue(`Opening worktree for branch "${targetWorktree.branch}"...`)); - } - else if (targetWorktree.detached) { - console.log(chalk.blue(`Opening detached worktree at ${targetWorktree.head.substring(0, 7)}...`)); - } - else { - console.log(chalk.blue(`Opening worktree at ${targetPath}...`)); - } - // Show status indicators - if (targetWorktree.locked) { - console.log(chalk.yellow(`Note: This worktree is locked${targetWorktree.lockReason ? `: ${targetWorktree.lockReason}` : ''}`)); - } - if (targetWorktree.prunable) { - console.log(chalk.yellow(`Warning: This worktree is marked as prunable${targetWorktree.pruneReason ? `: ${targetWorktree.pruneReason}` : ''}`)); - } // Open in the specified editor (or use configured default) const configuredEditor = getDefaultEditor(); const editorCommand = options.editor || configuredEditor; if (shouldSkipEditor(editorCommand)) { - console.log(chalk.gray(`Editor set to 'none', skipping editor open.`)); - console.log(chalk.green(`Worktree path: ${targetPath}`)); + process.stdout.write(targetPath + "\n"); } else { + // Display worktree info + if (targetWorktree.branch) { + console.log(chalk.blue(`Opening worktree for branch "${targetWorktree.branch}"...`)); + } + else if (targetWorktree.detached) { + console.log(chalk.blue(`Opening detached worktree at ${targetWorktree.head.substring(0, 7)}...`)); + } + else { + console.log(chalk.blue(`Opening worktree at ${targetPath}...`)); + } + // Show status indicators + if (targetWorktree.locked) { + console.log(chalk.yellow(`Note: This worktree is locked${targetWorktree.lockReason ? `: ${targetWorktree.lockReason}` : ''}`)); + } + if (targetWorktree.prunable) { + console.log(chalk.yellow(`Warning: This worktree is marked as prunable${targetWorktree.pruneReason ? `: ${targetWorktree.pruneReason}` : ''}`)); + } console.log(chalk.blue(`Opening ${targetPath} in ${editorCommand}...`)); try { await execa(editorCommand, [targetPath], { stdio: "inherit" }); console.log(chalk.green(`Successfully opened worktree in ${editorCommand}.`)); } catch (editorError) { - console.error(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`)); + process.stderr.write(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`) + "\n"); process.exit(1); } } } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Failed to open worktree:"), error.message); - } - else { - console.error(chalk.red("Failed to open worktree:"), error); - } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(chalk.red("Failed to open worktree: ") + message + "\n"); process.exit(1); } } diff --git a/build/index.js b/build/index.js index 587bff7..4f9ce80 100755 --- a/build/index.js +++ b/build/index.js @@ -10,6 +10,8 @@ import { configHandler } from "./commands/config.js"; import { prWorktreeHandler } from "./commands/pr.js"; import { openWorktreeHandler } from "./commands/open.js"; import { extractWorktreeHandler } from "./commands/extract.js"; +import { cdWorktreeHandler } from "./commands/cd.js"; +import { initHandler } from "./commands/init.js"; const program = new Command(); program .name("wt") @@ -75,6 +77,17 @@ program .option("-e, --editor ", "Editor to use for opening the worktree (overrides default editor)") .description("Open an existing worktree in the editor.") .action(openWorktreeHandler); +program + .command("cd") + .argument("[pathOrBranch]", "Path to worktree or branch name") + .option("--print", "Print the resolved path to stdout instead of spawning a subshell") + .description("Opens a subshell in the selected worktree directory.") + .action(cdWorktreeHandler); +program + .command("init") + .argument("[shell]", "Shell to generate integration for (zsh, bash, fish)") + .description("Output shell integration function for native cd support.") + .action(initHandler); program .command("extract") .argument("[branchName]", "Name of the branch to extract (defaults to current branch)") diff --git a/build/utils/tui.js b/build/utils/tui.js index 1acf030..84ecea3 100644 --- a/build/utils/tui.js +++ b/build/utils/tui.js @@ -12,7 +12,7 @@ import { getWorktrees } from "./git.js"; * @returns Selected worktree(s) or null if cancelled */ export async function selectWorktree(options) { - const { message = "Select a worktree", excludeMain = false, multiSelect = false } = options; + const { message = "Select a worktree", excludeMain = false, multiSelect = false, stdout } = options; const worktrees = await getWorktrees(); if (worktrees.length === 0) { console.log(chalk.yellow("No worktrees found.")); @@ -38,6 +38,7 @@ export async function selectWorktree(options) { choices, hint: '- Space to select. Enter to confirm.', instructions: false, + ...(stdout ? { stdout } : {}), }); if (!response.worktrees || response.worktrees.length === 0) { return null; @@ -45,7 +46,7 @@ export async function selectWorktree(options) { return response.worktrees; } else { - const response = await prompts({ + const promptOpts = { type: 'autocomplete', name: 'worktree', message, @@ -54,7 +55,10 @@ export async function selectWorktree(options) { const lowercaseInput = input.toLowerCase(); return Promise.resolve(choices.filter((choice) => choice.title.toLowerCase().includes(lowercaseInput))); }, - }); + }; + if (stdout) + promptOpts.stdout = stdout; + const response = await prompts(promptOpts); return response.worktree; } } diff --git a/src/commands/cd.ts b/src/commands/cd.ts new file mode 100644 index 0000000..a7ae21e --- /dev/null +++ b/src/commands/cd.ts @@ -0,0 +1,122 @@ +import { execa } from "execa"; +import chalk from "chalk"; +import { stat } from "node:fs/promises"; +import { constants } from "node:os"; +import { resolve } from "node:path"; +import { findWorktreeByBranch, findWorktreeByPath, WorktreeInfo } from "../utils/git.js"; +import { selectWorktree } from "../utils/tui.js"; + +function isEnoent(err: unknown): boolean { + return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +export async function cdWorktreeHandler(pathOrBranch: string = "", options: { print?: boolean } = {}) { + try { + const gitCheck = await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + if (gitCheck.exitCode !== 0 || gitCheck.stdout.trim() !== "true") { + process.stderr.write(chalk.red("Not inside a git work tree.") + "\n"); + process.exit(1); + } + + let targetWorktree: WorktreeInfo | null = null; + + if (!pathOrBranch) { + const selected = await selectWorktree({ + message: "Select a worktree to cd into", + excludeMain: false, + ...(options.print ? { stdout: process.stderr } : {}), + }); + + if (!selected || Array.isArray(selected)) { + process.stderr.write(chalk.yellow("No worktree selected.") + "\n"); + process.exit(0); + } + + targetWorktree = selected; + } else { + // Check if argument is an existing filesystem path + let pathStats: Awaited> | null = null; + try { + pathStats = await stat(pathOrBranch); + } catch (err: unknown) { + if (!isEnoent(err)) throw err; + // ENOENT: not a valid path, will try as branch name below + } + + if (pathStats) { + if (pathStats.isDirectory()) { + targetWorktree = await findWorktreeByPath(pathOrBranch); + if (!targetWorktree) { + try { + await stat(resolve(pathOrBranch, ".git")); + targetWorktree = { + path: resolve(pathOrBranch), + head: '', + branch: null, + detached: false, + locked: false, + prunable: false, + isMain: false, + bare: false, + }; + } catch { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); + process.exit(1); + } + } + } else { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" is not a directory.`) + "\n"); + process.exit(1); + } + } + + if (!targetWorktree) { + targetWorktree = await findWorktreeByBranch(pathOrBranch); + if (!targetWorktree) { + process.stderr.write(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`) + "\n"); + process.stderr.write(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt cd' without arguments to select interactively.") + "\n"); + process.exit(1); + } + } + } + + const targetPath = targetWorktree.path; + + try { + await stat(targetPath); + } catch (err: unknown) { + if (!isEnoent(err)) throw err; + process.stderr.write(chalk.red(`The worktree path "${targetPath}" no longer exists.`) + "\n"); + process.stderr.write(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.") + "\n"); + process.exit(1); + } + + if (options.print) { + process.stdout.write(targetPath + "\n"); + return; + } + + // Spawn a subshell in the target directory so cd works without shell config + const shell = process.platform === "win32" + ? process.env.COMSPEC || "cmd.exe" + : process.env.SHELL || "/bin/sh"; + process.stderr.write(chalk.green(`Entering ${targetPath}`) + "\n"); + process.stderr.write(chalk.dim(`(exit or ctrl+d to return)`) + "\n"); + const result = await execa(shell, [], { + cwd: targetPath, + stdio: "inherit", + reject: false, + }); + if (result.signal) { + const signum = constants.signals[result.signal as keyof typeof constants.signals] ?? 0; + process.exit(128 + signum); + } + if (result.exitCode != null && result.exitCode !== 0) { + process.exit(result.exitCode); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(chalk.red("Failed to resolve worktree: ") + message + "\n"); + process.exit(1); + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..51b0e07 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,122 @@ +import chalk from "chalk"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +// Shell wrapper functions that intercept `wt cd` to perform a native directory +// change via `--print`. If `--print` exits non-zero (e.g. no worktree found), +// falls back to the normal subshell-based `wt cd`. +const BASH_ZSH_FUNCTION = `wt() { + if [[ "\$1" == "cd" ]]; then + shift + local dir + dir=$(command wt cd --print "\$@") + if [[ \$? -eq 0 && -n "\$dir" ]]; then + builtin cd "\$dir" + else + command wt cd "\$@" + fi + else + command wt "\$@" + fi +}`; + +const FISH_FUNCTION = `function wt + if test (count $argv) -gt 0 -a "$argv[1]" = "cd" + set -l dir (command wt cd --print $argv[2..-1]) + if test $status -eq 0 -a -n "$dir" + builtin cd $dir + else + command wt cd $argv[2..-1] + end + else + command wt $argv + end +end`; + +const SUPPORTED_SHELLS = ["zsh", "bash", "fish"] as const; +type Shell = (typeof SUPPORTED_SHELLS)[number]; + +const SHELL_RC: Record = { + zsh: { file: "~/.zshrc", line: 'eval "$(wt init zsh)"' }, + bash: { file: "~/.bashrc", line: 'eval "$(wt init bash)"' }, + fish: { file: "~/.config/fish/config.fish", line: "wt init fish | source" }, +}; + +export function getShellFunction(shell: Shell): string { + if (shell === "fish") return FISH_FUNCTION; + return BASH_ZSH_FUNCTION; +} + +export function detectShell(): Shell | null { + const shellEnv = process.env.SHELL || ""; + const basename = shellEnv.split("/").pop()?.toLowerCase() || ""; + if (SUPPORTED_SHELLS.includes(basename as Shell)) return basename as Shell; + return null; +} + +function expandTilde(path: string): string { + return path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path; +} + +function isAlreadyInstalled(rc: { file: string; line: string }): boolean { + try { + const content = readFileSync(expandTilde(rc.file), "utf-8"); + return content.includes(rc.line); + } catch { + return false; + } +} + +const SHELL_SOURCE: Record = { + zsh: "source ~/.zshrc", + bash: "source ~/.bashrc", + fish: "source ~/.config/fish/config.fish", +}; + +export function initHandler(shell?: string): void { + let resolved: Shell; + + if (shell) { + const normalized = shell.toLowerCase(); + if (!SUPPORTED_SHELLS.includes(normalized as Shell)) { + process.stderr.write( + chalk.red(`Unsupported shell: "${shell}". Supported shells: ${SUPPORTED_SHELLS.join(", ")}`) + "\n" + ); + process.exit(1); + } + resolved = normalized as Shell; + } else { + const detected = detectShell(); + if (!detected) { + process.stderr.write( + chalk.red("Could not detect shell from $SHELL.") + "\n" + ); + process.stderr.write( + chalk.yellow(`Usage: wt init <${SUPPORTED_SHELLS.join("|")}>`) + "\n" + ); + process.exit(1); + } + resolved = detected; + process.stderr.write(chalk.dim(`Detected shell: ${resolved}`) + "\n"); + } + + process.stdout.write(getShellFunction(resolved) + "\n"); + + const rc = SHELL_RC[resolved]; + + if (isAlreadyInstalled(rc)) { + process.stderr.write(chalk.green(`Already installed in ${rc.file}`) + "\n"); + return; + } + + process.stderr.write(chalk.dim(`# Add to ${rc.file}:`) + "\n"); + process.stderr.write(chalk.dim("# " + rc.line) + "\n"); + process.stderr.write("\n"); + process.stderr.write(chalk.dim("# Run this to add it automatically:") + "\n"); + const appendCmd = `echo '${rc.line}' >> ${rc.file}`; + process.stderr.write(" " + chalk.cyan(appendCmd) + "\n"); + process.stderr.write("\n"); + process.stderr.write(chalk.dim("# Then reload your shell:") + "\n"); + process.stderr.write(" " + chalk.cyan(SHELL_SOURCE[resolved]) + "\n"); +} diff --git a/src/commands/open.ts b/src/commands/open.ts index d971c33..3dd226e 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -3,16 +3,20 @@ import chalk from "chalk"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import { getDefaultEditor, shouldSkipEditor } from "../config.js"; -import { getWorktrees, findWorktreeByBranch, findWorktreeByPath, WorktreeInfo } from "../utils/git.js"; +import { findWorktreeByBranch, findWorktreeByPath, WorktreeInfo } from "../utils/git.js"; import { selectWorktree } from "../utils/tui.js"; +function isEnoent(err: unknown): boolean { + return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + export async function openWorktreeHandler( pathOrBranch: string = "", options: { editor?: string } ) { try { // 1. Validate we're in a git repo - await execa("git", ["rev-parse", "--is-inside-work-tree"]); + await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); let targetWorktree: WorktreeInfo | null = null; @@ -24,23 +28,27 @@ export async function openWorktreeHandler( }); if (!selected || Array.isArray(selected)) { - console.log(chalk.yellow("No worktree selected.")); + process.stderr.write(chalk.yellow("No worktree selected.") + "\n"); process.exit(0); } targetWorktree = selected; } else { // Try to find by path first + let pathStats: Awaited> | null = null; try { - const stats = await stat(pathOrBranch); - if (stats.isDirectory()) { + pathStats = await stat(pathOrBranch); + } catch (err: unknown) { + if (!isEnoent(err)) throw err; + // ENOENT: not a valid path, will try as branch name below + } + + if (pathStats) { + if (pathStats.isDirectory()) { targetWorktree = await findWorktreeByPath(pathOrBranch); if (!targetWorktree) { - // It's a directory but not a registered worktree - // Still try to open it if it has .git try { await stat(resolve(pathOrBranch, ".git")); - // It's a git worktree, create a minimal info object targetWorktree = { path: resolve(pathOrBranch), head: '', @@ -52,21 +60,22 @@ export async function openWorktreeHandler( bare: false, }; } catch { - console.error(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`)); + process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); process.exit(1); } } + } else { + process.stderr.write(chalk.red(`The path "${pathOrBranch}" is not a directory.`) + "\n"); + process.exit(1); } - } catch { - // Not a valid path, try as branch name } // If not found by path, try by branch name if (!targetWorktree) { targetWorktree = await findWorktreeByBranch(pathOrBranch); if (!targetWorktree) { - console.error(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`)); - console.error(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt open' without arguments to select interactively.")); + process.stderr.write(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`) + "\n"); + process.stderr.write(chalk.yellow("Use 'wt list' to see existing worktrees, or run 'wt open' without arguments to select interactively.") + "\n"); process.exit(1); } } @@ -77,53 +86,50 @@ export async function openWorktreeHandler( // Verify the target path exists try { await stat(targetPath); - } catch { - console.error(chalk.red(`The worktree path "${targetPath}" no longer exists.`)); - console.error(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.")); + } catch (err: unknown) { + if (!isEnoent(err)) throw err; + process.stderr.write(chalk.red(`The worktree path "${targetPath}" no longer exists.`) + "\n"); + process.stderr.write(chalk.yellow("The worktree may have been removed. Run 'git worktree prune' to clean up.") + "\n"); process.exit(1); } - // Display worktree info - if (targetWorktree.branch) { - console.log(chalk.blue(`Opening worktree for branch "${targetWorktree.branch}"...`)); - } else if (targetWorktree.detached) { - console.log(chalk.blue(`Opening detached worktree at ${targetWorktree.head.substring(0, 7)}...`)); - } else { - console.log(chalk.blue(`Opening worktree at ${targetPath}...`)); - } - - // Show status indicators - if (targetWorktree.locked) { - console.log(chalk.yellow(`Note: This worktree is locked${targetWorktree.lockReason ? `: ${targetWorktree.lockReason}` : ''}`)); - } - if (targetWorktree.prunable) { - console.log(chalk.yellow(`Warning: This worktree is marked as prunable${targetWorktree.pruneReason ? `: ${targetWorktree.pruneReason}` : ''}`)); - } - // Open in the specified editor (or use configured default) const configuredEditor = getDefaultEditor(); const editorCommand = options.editor || configuredEditor; if (shouldSkipEditor(editorCommand)) { - console.log(chalk.gray(`Editor set to 'none', skipping editor open.`)); - console.log(chalk.green(`Worktree path: ${targetPath}`)); + process.stdout.write(targetPath + "\n"); } else { + // Display worktree info + if (targetWorktree.branch) { + console.log(chalk.blue(`Opening worktree for branch "${targetWorktree.branch}"...`)); + } else if (targetWorktree.detached) { + console.log(chalk.blue(`Opening detached worktree at ${targetWorktree.head.substring(0, 7)}...`)); + } else { + console.log(chalk.blue(`Opening worktree at ${targetPath}...`)); + } + + // Show status indicators + if (targetWorktree.locked) { + console.log(chalk.yellow(`Note: This worktree is locked${targetWorktree.lockReason ? `: ${targetWorktree.lockReason}` : ''}`)); + } + if (targetWorktree.prunable) { + console.log(chalk.yellow(`Warning: This worktree is marked as prunable${targetWorktree.pruneReason ? `: ${targetWorktree.pruneReason}` : ''}`)); + } + console.log(chalk.blue(`Opening ${targetPath} in ${editorCommand}...`)); try { await execa(editorCommand, [targetPath], { stdio: "inherit" }); console.log(chalk.green(`Successfully opened worktree in ${editorCommand}.`)); } catch (editorError) { - console.error(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`)); + process.stderr.write(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`) + "\n"); process.exit(1); } } } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Failed to open worktree:"), error.message); - } else { - console.error(chalk.red("Failed to open worktree:"), error); - } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(chalk.red("Failed to open worktree: ") + message + "\n"); process.exit(1); } } diff --git a/src/index.ts b/src/index.ts index beaa0e9..594b188 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import { configHandler } from "./commands/config.js"; import { prWorktreeHandler } from "./commands/pr.js"; import { openWorktreeHandler } from "./commands/open.js"; import { extractWorktreeHandler } from "./commands/extract.js"; +import { cdWorktreeHandler } from "./commands/cd.js"; +import { initHandler } from "./commands/init.js"; const program = new Command(); @@ -149,6 +151,19 @@ program .description("Open an existing worktree in the editor.") .action(openWorktreeHandler); +program + .command("cd") + .argument("[pathOrBranch]", "Path to worktree or branch name") + .option("--print", "Print the resolved path to stdout instead of spawning a subshell") + .description("Opens a subshell in the selected worktree directory.") + .action(cdWorktreeHandler); + +program + .command("init") + .argument("[shell]", "Shell to generate integration for (zsh, bash, fish)") + .description("Output shell integration function for native cd support.") + .action(initHandler); + program .command("extract") .argument("[branchName]", "Name of the branch to extract (defaults to current branch)") diff --git a/src/utils/tui.ts b/src/utils/tui.ts index 6aa760c..a1f881a 100644 --- a/src/utils/tui.ts +++ b/src/utils/tui.ts @@ -16,8 +16,9 @@ export async function selectWorktree(options: { message?: string; excludeMain?: boolean; multiSelect?: boolean; + stdout?: NodeJS.WritableStream; }): Promise { - const { message = "Select a worktree", excludeMain = false, multiSelect = false } = options; + const { message = "Select a worktree", excludeMain = false, multiSelect = false, stdout } = options; const worktrees = await getWorktrees(); @@ -48,7 +49,8 @@ export async function selectWorktree(options: { choices, hint: '- Space to select. Enter to confirm.', instructions: false, - }); + ...(stdout ? { stdout } : {}), + } as any); if (!response.worktrees || response.worktrees.length === 0) { return null; @@ -56,12 +58,12 @@ export async function selectWorktree(options: { return response.worktrees as WorktreeInfo[]; } else { - const response = await prompts({ + const promptOpts: prompts.PromptObject = { type: 'autocomplete', name: 'worktree', message, choices, - suggest: (input, choices) => { + suggest: (input: string, choices: any[]) => { const lowercaseInput = input.toLowerCase(); return Promise.resolve( choices.filter((choice: any) => @@ -69,7 +71,9 @@ export async function selectWorktree(options: { ) ); }, - }); + }; + if (stdout) (promptOpts as any).stdout = stdout; + const response = await prompts(promptOpts); return response.worktree as WorktreeInfo | null; } diff --git a/test/cd.test.ts b/test/cd.test.ts new file mode 100644 index 0000000..84e8d46 --- /dev/null +++ b/test/cd.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { WorktreeInfo } from '../src/utils/git.js'; + +/** + * Unit tests for the `wt cd` command handler. + * + * These mock all dependencies (git utils, TUI, fs, execa) to test + * the handler's branching logic, subshell spawning, stderr routing, + * and exit codes in isolation. + */ + +const mockWorktrees: WorktreeInfo[] = [ + { + path: '/fake/repo', + head: 'aaa111', + branch: 'main', + detached: false, + locked: false, + prunable: false, + isMain: true, + bare: false, + }, + { + path: '/fake/worktrees/feature-login', + head: 'bbb222', + branch: 'feature/login', + detached: false, + locked: false, + prunable: false, + isMain: false, + bare: false, + }, + { + path: '/fake/worktrees/detached-wt', + head: 'ccc333', + branch: null, + detached: true, + locked: false, + prunable: false, + isMain: false, + bare: false, + }, +]; + +// --- Mocks --- + +const mockExeca = vi.fn(async () => ({ stdout: 'true', exitCode: 0 })); +vi.mock('execa', () => ({ execa: mockExeca })); + +const mockFindByBranch = vi.fn(async () => null as WorktreeInfo | null); +const mockFindByPath = vi.fn(async () => null as WorktreeInfo | null); +vi.mock('../src/utils/git.js', () => ({ + findWorktreeByBranch: mockFindByBranch, + findWorktreeByPath: mockFindByPath, + getWorktrees: vi.fn(async () => mockWorktrees), +})); + +const mockSelectWorktree = vi.fn(async () => null as WorktreeInfo | WorktreeInfo[] | null); +vi.mock('../src/utils/tui.js', () => ({ + selectWorktree: mockSelectWorktree, +})); + +function enoentError(): Error { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; +} + +const mockStat = vi.fn(async () => { throw enoentError(); }); +vi.mock('node:fs/promises', () => ({ + stat: mockStat, +})); + +describe('cdWorktreeHandler', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; + const originalShell = process.env.SHELL; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as any); + + process.env.SHELL = '/bin/zsh'; + + // Reset all mocks to defaults + mockExeca.mockReset().mockResolvedValue({ stdout: 'true', exitCode: 0 } as any); + mockFindByBranch.mockReset().mockResolvedValue(null); + mockFindByPath.mockReset().mockResolvedValue(null); + mockSelectWorktree.mockReset().mockResolvedValue(null); + mockStat.mockReset().mockRejectedValue(enoentError()); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + process.env.SHELL = originalShell; + }); + + // --- Branch name resolution --- + + it('should spawn subshell in worktree dir when branch is found', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // stat for input: not a path (throws) + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[1]); + // stat for target path verification: exists + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('feature/login'); + + expect(mockExeca).toHaveBeenCalledWith('/bin/zsh', [], { + cwd: '/fake/worktrees/feature-login', + stdio: 'inherit', + reject: false, + }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('should not write anything to stdout on branch not found', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockFindByBranch.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('no-such-branch')).rejects.toThrow('process.exit'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should write error to stderr when branch not found', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockFindByBranch.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('missing')).rejects.toThrow('process.exit'); + + expect(stderrSpy).toHaveBeenCalled(); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Could not find a worktree'); + expect(stderrOutput).toContain('missing'); + }); + + it('should suggest wt list and wt cd in error for missing branch', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockFindByBranch.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('nope')).rejects.toThrow('process.exit'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('wt list'); + expect(stderrOutput).toContain('wt cd'); + }); + + // --- Path resolution --- + + it('should resolve by path when argument is an existing directory worktree', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // First stat: path exists and is a directory + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockFindByPath.mockResolvedValueOnce(mockWorktrees[1]); + // Second stat: target path verification + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('/fake/worktrees/feature-login'); + + expect(mockExeca).toHaveBeenCalledWith('/bin/zsh', [], { + cwd: '/fake/worktrees/feature-login', + stdio: 'inherit', + reject: false, + }); + }); + + it('should fail when path exists but is not a directory', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // stat returns a file, not a directory + mockStat.mockResolvedValueOnce({ isDirectory: () => false } as any); + + await expect(cdWorktreeHandler('/tmp/somefile.txt')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('is not a directory'); + }); + + it('should fail when path is a directory but not a git worktree', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // First stat: path exists, is directory + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + // findWorktreeByPath returns null + mockFindByPath.mockResolvedValueOnce(null); + // stat for .git inside directory: throws (no .git) + mockStat.mockRejectedValueOnce(enoentError()); + + await expect(cdWorktreeHandler('/tmp/plain-dir')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('not a git worktree'); + }); + + it('should fall through to branch lookup when path does not exist', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // stat throws → not a path, fall through to branch lookup + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + // stat for target path verification + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('main'); + + expect(mockExeca).toHaveBeenCalledWith('/bin/zsh', [], { + cwd: '/fake/repo', + stdio: 'inherit', + reject: false, + }); + }); + + // --- Target path deleted from disk --- + + it('should fail when resolved worktree path no longer exists on disk', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + // stat for input: throws (not a path) + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[1]); + // stat for target path verification: throws (deleted) + mockStat.mockRejectedValueOnce(enoentError()); + + await expect(cdWorktreeHandler('feature/login')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('no longer exists'); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + // --- Interactive selection (no argument) --- + + it('should use interactive picker when no argument is given', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockSelectWorktree.mockResolvedValueOnce(mockWorktrees[1]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler(''); + + expect(mockSelectWorktree).toHaveBeenCalledWith({ + message: 'Select a worktree to cd into', + excludeMain: false, + }); + expect(mockExeca).toHaveBeenCalledWith('/bin/zsh', [], { + cwd: '/fake/worktrees/feature-login', + stdio: 'inherit', + reject: false, + }); + }); + + it('should exit 0 when user cancels interactive selection', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockSelectWorktree.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it('should exit 0 when interactive selection returns array', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockSelectWorktree.mockResolvedValueOnce([mockWorktrees[0], mockWorktrees[1]]); + + await expect(cdWorktreeHandler('')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it('should write cancellation message to stderr not stdout', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockSelectWorktree.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('')).rejects.toThrow('process.exit'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('No worktree selected'); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + // --- Not in a git repo --- + + it('should fail when not inside a git repository', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockExeca.mockResolvedValueOnce({ stdout: '', exitCode: 128 } as any); + + await expect(cdWorktreeHandler('main')).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it('should write git error to stderr when not in a repo', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockExeca.mockResolvedValueOnce({ stdout: '', exitCode: 128 } as any); + + await expect(cdWorktreeHandler('main')).rejects.toThrow('process.exit'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Not inside a git work tree'); + }); + + // --- Subshell uses correct shell --- + + it('should write entering message to stderr', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('main'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Entering'); + expect(stderrOutput).toContain('/fake/repo'); + }); + + it('should not write path to stdout', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('main'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it('should propagate non-zero shell exit code', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + mockStat.mockResolvedValueOnce({} as any); + // Subshell exits with code 130 + mockExeca + .mockResolvedValueOnce({ stdout: 'true', exitCode: 0 } as any) // git rev-parse + .mockResolvedValueOnce({ exitCode: 130 } as any); // shell + + await expect(cdWorktreeHandler('main')).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(130); + }); + + it('should exit 128 when shell is killed by signal', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + mockStat.mockResolvedValueOnce({} as any); + mockExeca + .mockResolvedValueOnce({ stdout: 'true', exitCode: 0 } as any) // git rev-parse + .mockResolvedValueOnce({ exitCode: undefined, signal: 'SIGKILL' } as any); // shell killed + + await expect(cdWorktreeHandler('main')).rejects.toThrow('process.exit'); + // SIGKILL = 9, so exit code should be 128 + 9 = 137 + expect(exitSpy).toHaveBeenCalledWith(137); + }); + + // --- --print mode --- + + it('should write only the path to stdout with --print', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[1]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('feature/login', { print: true }); + + expect(stdoutSpy).toHaveBeenCalledWith('/fake/worktrees/feature-login\n'); + // Should only have the git rev-parse call, no subshell spawn + expect(mockExeca).toHaveBeenCalledTimes(1); + expect(mockExeca).toHaveBeenCalledWith('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }); + }); + + it('should not write entering message with --print', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockStat.mockRejectedValueOnce(enoentError()); + mockFindByBranch.mockResolvedValueOnce(mockWorktrees[0]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler('main', { print: true }); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).not.toContain('Entering'); + }); + + it('should still exit 1 on error with --print', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockFindByBranch.mockResolvedValueOnce(null); + + await expect(cdWorktreeHandler('missing', { print: true })).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + // --- Detached worktree --- + + it('should handle detached worktrees from interactive selection', async () => { + const { cdWorktreeHandler } = await import('../src/commands/cd.js'); + + mockSelectWorktree.mockResolvedValueOnce(mockWorktrees[2]); + mockStat.mockResolvedValueOnce({} as any); + + await cdWorktreeHandler(''); + + expect(mockExeca).toHaveBeenCalledWith('/bin/zsh', [], { + cwd: '/fake/worktrees/detached-wt', + stdio: 'inherit', + reject: false, + }); + }); +}); diff --git a/test/init.test.ts b/test/init.test.ts new file mode 100644 index 0000000..55b4c86 --- /dev/null +++ b/test/init.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockReadFileSync = vi.fn(() => { throw new Error('ENOENT'); }); +vi.mock('node:fs', () => ({ readFileSync: mockReadFileSync })); + +describe('initHandler', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as any); + mockReadFileSync.mockReset().mockImplementation(() => { throw new Error('ENOENT'); }); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('should output bash/zsh shell function to stdout', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('wt()'); + expect(output).toContain('command wt cd --print'); + expect(output).toContain('builtin cd'); + }); + + it('should output bash function for bash shell', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('bash'); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('wt()'); + }); + + it('should output fish function for fish shell', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('fish'); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('function wt'); + expect(output).toContain('$argv'); + }); + + it('should write usage hint to stderr for zsh', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('eval'); + expect(stderrOutput).toContain('.zshrc'); + }); + + it('should write config.fish hint for fish shell', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('fish'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('config.fish'); + expect(stderrOutput).not.toContain('.fishrc'); + }); + + it('should include copyable append command in stderr', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain("echo 'eval \"$(wt init zsh)\"' >> ~/.zshrc"); + }); + + it('should include fish append command for fish shell', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('fish'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain("echo 'wt init fish | source' >> ~/.config/fish/config.fish"); + }); + + it('should exit 1 for unsupported shell', async () => { + const { initHandler } = await import('../src/commands/init.js'); + + expect(() => initHandler('powershell')).toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Unsupported shell'); + }); + + it('should be case-insensitive for shell name', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('ZSH'); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('wt()'); + }); + + it('should not contain 2>/dev/null in shell functions', async () => { + const { getShellFunction } = await import('../src/commands/init.js'); + expect(getShellFunction('zsh')).not.toContain('2>/dev/null'); + expect(getShellFunction('fish')).not.toContain('2>/dev/null'); + }); + + it('should include reload command in stderr', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('source ~/.zshrc'); + }); + + it('should include fish reload command for fish', async () => { + const { initHandler } = await import('../src/commands/init.js'); + initHandler('fish'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('source ~/.config/fish/config.fish'); + }); + + it('should show "already installed" when init line exists in rc file', async () => { + mockReadFileSync.mockReturnValueOnce('some stuff\neval "$(wt init zsh)"\nmore stuff'); + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Already installed'); + expect(stderrOutput).toContain('.zshrc'); + // Should not show append/reload commands + expect(stderrOutput).not.toContain("echo '"); + expect(stderrOutput).not.toContain('source'); + }); + + it('should still output shell function even when already installed', async () => { + mockReadFileSync.mockReturnValueOnce('eval "$(wt init zsh)"'); + const { initHandler } = await import('../src/commands/init.js'); + initHandler('zsh'); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('wt()'); + }); +}); + +describe('detectShell', () => { + const originalShell = process.env.SHELL; + + afterEach(() => { + process.env.SHELL = originalShell; + }); + + it('should detect zsh from $SHELL', async () => { + process.env.SHELL = '/bin/zsh'; + const { detectShell } = await import('../src/commands/init.js'); + expect(detectShell()).toBe('zsh'); + }); + + it('should detect bash from $SHELL', async () => { + process.env.SHELL = '/usr/bin/bash'; + const { detectShell } = await import('../src/commands/init.js'); + expect(detectShell()).toBe('bash'); + }); + + it('should detect fish from $SHELL', async () => { + process.env.SHELL = '/usr/local/bin/fish'; + const { detectShell } = await import('../src/commands/init.js'); + expect(detectShell()).toBe('fish'); + }); + + it('should return null for unsupported $SHELL', async () => { + process.env.SHELL = '/bin/tcsh'; + const { detectShell } = await import('../src/commands/init.js'); + expect(detectShell()).toBeNull(); + }); + + it('should return null when $SHELL is unset', async () => { + delete process.env.SHELL; + const { detectShell } = await import('../src/commands/init.js'); + expect(detectShell()).toBeNull(); + }); +}); + +describe('initHandler auto-detection', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; + const originalShell = process.env.SHELL; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as any); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + process.env.SHELL = originalShell; + }); + + it('should auto-detect shell when no argument given', async () => { + process.env.SHELL = '/bin/zsh'; + const { initHandler } = await import('../src/commands/init.js'); + initHandler(); + + const output = stdoutSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('wt()'); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Detected shell: zsh'); + }); + + it('should exit 1 when no argument and $SHELL is unsupported', async () => { + process.env.SHELL = '/bin/tcsh'; + const { initHandler } = await import('../src/commands/init.js'); + + expect(() => initHandler()).toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('Could not detect shell'); + }); +}); + +describe('getShellFunction', () => { + it('should return same function for bash and zsh', async () => { + const { getShellFunction } = await import('../src/commands/init.js'); + expect(getShellFunction('bash')).toBe(getShellFunction('zsh')); + }); + + it('should return different function for fish', async () => { + const { getShellFunction } = await import('../src/commands/init.js'); + expect(getShellFunction('fish')).not.toBe(getShellFunction('zsh')); + }); +}); From f55a34076c78710c013c1143ac2b05b4ea35bc71 Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 17:33:13 +0100 Subject: [PATCH 2/6] fix: Show clear error in wt open when not inside a git repo Powered by human calories and mass GPU cycles. --- build/commands/open.js | 6 +++++- src/commands/open.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/build/commands/open.js b/build/commands/open.js index bdf3597..e1ffd49 100644 --- a/build/commands/open.js +++ b/build/commands/open.js @@ -11,7 +11,11 @@ function isEnoent(err) { export async function openWorktreeHandler(pathOrBranch = "", options) { try { // 1. Validate we're in a git repo - await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + const { exitCode } = await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + if (exitCode !== 0) { + process.stderr.write(chalk.red("Not inside a git repository.") + "\n"); + process.exit(1); + } let targetWorktree = null; // Improvement #4: Interactive TUI for missing arguments if (!pathOrBranch) { diff --git a/src/commands/open.ts b/src/commands/open.ts index 3dd226e..b66b12d 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -16,7 +16,11 @@ export async function openWorktreeHandler( ) { try { // 1. Validate we're in a git repo - await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + const { exitCode } = await execa("git", ["rev-parse", "--is-inside-work-tree"], { reject: false }); + if (exitCode !== 0) { + process.stderr.write(chalk.red("Not inside a git repository.") + "\n"); + process.exit(1); + } let targetWorktree: WorktreeInfo | null = null; From 28fcb9ff1d59d17d1c68c09ff09154cbb8473228 Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 17:41:29 +0100 Subject: [PATCH 3/6] fix: Narrow .git stat catch to ENOENT in wt open Powered by human calories and mass GPU cycles. --- build/commands/open.js | 4 +++- src/commands/open.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build/commands/open.js b/build/commands/open.js index e1ffd49..7f403af 100644 --- a/build/commands/open.js +++ b/build/commands/open.js @@ -57,7 +57,9 @@ export async function openWorktreeHandler(pathOrBranch = "", options) { bare: false, }; } - catch { + catch (gitErr) { + if (!isEnoent(gitErr)) + throw gitErr; process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); process.exit(1); } diff --git a/src/commands/open.ts b/src/commands/open.ts index b66b12d..8973f99 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -63,7 +63,8 @@ export async function openWorktreeHandler( isMain: false, bare: false, }; - } catch { + } catch (gitErr: unknown) { + if (!isEnoent(gitErr)) throw gitErr; process.stderr.write(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`) + "\n"); process.exit(1); } From 32417dcc2f4323b18dbb051ea77ca87402d8ac93 Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 19:09:22 +0100 Subject: [PATCH 4/6] fix: Suppress stderr output from `wt init` when called via eval When `eval "$(wt init zsh)"` runs from .zshrc on every terminal open, the "Already installed" message and setup hints were printed to stderr each time. Now `initHandler` returns silently after emitting the shell function when called with an explicit shell arg. Guidance output is reserved for the interactive `wt init` (auto-detect) path. Powered by human calories and mass GPU cycles. --- build/commands/init.js | 4 +++ src/commands/init.ts | 4 +++ test/init.test.ts | 57 +++++++++++++++++------------------------- 3 files changed, 31 insertions(+), 34 deletions(-) diff --git a/build/commands/init.js b/build/commands/init.js index b7f2318..b5d2db2 100644 --- a/build/commands/init.js +++ b/build/commands/init.js @@ -87,6 +87,10 @@ export function initHandler(shell) { process.stderr.write(chalk.dim(`Detected shell: ${resolved}`) + "\n"); } process.stdout.write(getShellFunction(resolved) + "\n"); + // When called with an explicit shell arg (e.g. `eval "$(wt init zsh)"`), + // the user just wants the shell function emitted to stdout — no guidance. + if (shell) + return; const rc = SHELL_RC[resolved]; if (isAlreadyInstalled(rc)) { process.stderr.write(chalk.green(`Already installed in ${rc.file}`) + "\n"); diff --git a/src/commands/init.ts b/src/commands/init.ts index 51b0e07..8fcb15c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -103,6 +103,10 @@ export function initHandler(shell?: string): void { process.stdout.write(getShellFunction(resolved) + "\n"); + // When called with an explicit shell arg (e.g. `eval "$(wt init zsh)"`), + // the user just wants the shell function emitted to stdout — no guidance. + if (shell) return; + const rc = SHELL_RC[resolved]; if (isAlreadyInstalled(rc)) { diff --git a/test/init.test.ts b/test/init.test.ts index 55b4c86..e6493ba 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -50,37 +50,33 @@ describe('initHandler', () => { expect(output).toContain('$argv'); }); - it('should write usage hint to stderr for zsh', async () => { + it('should be silent on stderr when called with explicit shell arg', async () => { const { initHandler } = await import('../src/commands/init.js'); initHandler('zsh'); const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(stderrOutput).toContain('eval'); - expect(stderrOutput).toContain('.zshrc'); - }); - - it('should write config.fish hint for fish shell', async () => { - const { initHandler } = await import('../src/commands/init.js'); - initHandler('fish'); - - const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(stderrOutput).toContain('config.fish'); - expect(stderrOutput).not.toContain('.fishrc'); + expect(stderrOutput).toBe(''); }); - it('should include copyable append command in stderr', async () => { + it('should show usage hint on stderr when auto-detecting shell', async () => { + process.env.SHELL = '/bin/zsh'; const { initHandler } = await import('../src/commands/init.js'); - initHandler('zsh'); + initHandler(); const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('eval'); + expect(stderrOutput).toContain('.zshrc'); expect(stderrOutput).toContain("echo 'eval \"$(wt init zsh)\"' >> ~/.zshrc"); }); - it('should include fish append command for fish shell', async () => { + it('should show fish hints when auto-detecting fish', async () => { + process.env.SHELL = '/usr/local/bin/fish'; const { initHandler } = await import('../src/commands/init.js'); - initHandler('fish'); + initHandler(); const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(stderrOutput).toContain('config.fish'); + expect(stderrOutput).not.toContain('.fishrc'); expect(stderrOutput).toContain("echo 'wt init fish | source' >> ~/.config/fish/config.fish"); }); @@ -108,33 +104,26 @@ describe('initHandler', () => { expect(getShellFunction('fish')).not.toContain('2>/dev/null'); }); - it('should include reload command in stderr', async () => { - const { initHandler } = await import('../src/commands/init.js'); - initHandler('zsh'); - - const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(stderrOutput).toContain('source ~/.zshrc'); - }); - - it('should include fish reload command for fish', async () => { + it('should show "already installed" when auto-detecting and init line exists', async () => { + process.env.SHELL = '/bin/zsh'; + mockReadFileSync.mockReturnValueOnce('some stuff\neval "$(wt init zsh)"\nmore stuff'); const { initHandler } = await import('../src/commands/init.js'); - initHandler('fish'); + initHandler(); const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(stderrOutput).toContain('source ~/.config/fish/config.fish'); + expect(stderrOutput).toContain('Already installed'); + expect(stderrOutput).toContain('.zshrc'); + // Should not show append/reload commands + expect(stderrOutput).not.toContain("echo '"); }); - it('should show "already installed" when init line exists in rc file', async () => { - mockReadFileSync.mockReturnValueOnce('some stuff\neval "$(wt init zsh)"\nmore stuff'); + it('should be silent on stderr when called with shell arg even if already installed', async () => { + mockReadFileSync.mockReturnValueOnce('eval "$(wt init zsh)"'); const { initHandler } = await import('../src/commands/init.js'); initHandler('zsh'); const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(stderrOutput).toContain('Already installed'); - expect(stderrOutput).toContain('.zshrc'); - // Should not show append/reload commands - expect(stderrOutput).not.toContain("echo '"); - expect(stderrOutput).not.toContain('source'); + expect(stderrOutput).toBe(''); }); it('should still output shell function even when already installed', async () => { From 107bf56e775e7404f54126928441bdee9d9d09db Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 19:32:36 +0100 Subject: [PATCH 5/6] Update src/commands/init.ts fix for fish Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/commands/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 8fcb15c..6285774 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -25,7 +25,7 @@ const FISH_FUNCTION = `function wt if test (count $argv) -gt 0 -a "$argv[1]" = "cd" set -l dir (command wt cd --print $argv[2..-1]) if test $status -eq 0 -a -n "$dir" - builtin cd $dir + builtin cd -- "$dir" else command wt cd $argv[2..-1] end From 65359928ddc8b86a80bf036c21f51954dede8cfc Mon Sep 17 00:00:00 2001 From: beasty Date: Fri, 30 Jan 2026 19:44:51 +0100 Subject: [PATCH 6/6] Update src/commands/init.ts also zsh fix Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/commands/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 6285774..3662dad 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -12,7 +12,7 @@ const BASH_ZSH_FUNCTION = `wt() { local dir dir=$(command wt cd --print "\$@") if [[ \$? -eq 0 && -n "\$dir" ]]; then - builtin cd "\$dir" + builtin cd -- "\$dir" else command wt cd "\$@" fi