From 87d7e98e6ba973f9d0d38f94998d1ae90888c088 Mon Sep 17 00:00:00 2001 From: Isaac Mayolas Date: Tue, 3 Mar 2026 15:55:57 +0100 Subject: [PATCH] feat: Add cd command to navigate to worktree directories Adds a new `wt cd` command that copies `cd /path/to/worktree` to the clipboard for quick navigation. Supports interactive worktree selection (no args) or direct lookup by branch name or path. - Shell-escapes paths to handle spaces and special characters - Cross-platform clipboard support (pbcopy, clip.exe, xclip) - Falls back to stdout with a message if clipboard is unavailable Co-Authored-By: Claude Opus 4.6 --- build/commands/cd.js | 110 ++++++++++++++++++++++++++++++++++++++++++ build/index.js | 6 +++ src/commands/cd.ts | 112 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 7 +++ 4 files changed, 235 insertions(+) create mode 100644 build/commands/cd.js create mode 100644 src/commands/cd.ts diff --git a/build/commands/cd.js b/build/commands/cd.js new file mode 100644 index 0000000..ca5e260 --- /dev/null +++ b/build/commands/cd.js @@ -0,0 +1,110 @@ +import { execa } from "execa"; +import chalk from "chalk"; +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { findWorktreeByBranch, findWorktreeByPath } from "../utils/git.js"; +import { selectWorktree } from "../utils/tui.js"; +export async function cdWorktreeHandler(pathOrBranch = "") { + try { + await execa("git", ["rev-parse", "--is-inside-work-tree"]); + let targetWorktree = null; + if (!pathOrBranch) { + const selected = await selectWorktree({ + message: "Select a worktree to navigate to", + excludeMain: false, + }); + if (!selected || Array.isArray(selected)) { + console.error(chalk.yellow("No worktree selected.")); + process.exit(0); + } + targetWorktree = selected; + } + else { + // Try to find by path first + try { + const stats = await stat(pathOrBranch); + if (stats.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 { + console.error(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`)); + process.exit(1); + } + } + } + } + catch { + // Not a valid path, try as 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 cd' without arguments to select interactively.")); + process.exit(1); + } + } + } + const targetPath = targetWorktree.path; + // 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.")); + process.exit(1); + } + // Shell-escape the path by wrapping in single quotes + const escapedPath = "'" + targetPath.replace(/'/g, "'\\''") + "'"; + const cdCommand = `cd ${escapedPath}`; + // Copy cd command to clipboard (cross-platform) + let clipboardCmd; + let clipboardArgs; + if (process.platform === "darwin") { + clipboardCmd = "pbcopy"; + clipboardArgs = []; + } + else if (process.platform === "win32") { + clipboardCmd = "clip.exe"; + clipboardArgs = []; + } + else { + clipboardCmd = "xclip"; + clipboardArgs = ["-selection", "clipboard"]; + } + try { + await execa(clipboardCmd, clipboardArgs, { input: cdCommand }); + console.log(chalk.green(`Copied to clipboard: ${cdCommand}`)); + const pasteHint = process.platform === "darwin" ? "Cmd+V" : "Ctrl+V"; + console.log(chalk.gray(`Paste with ${pasteHint} and press Enter to navigate.`)); + } + catch { + // Fallback if clipboard command is unavailable + console.error(chalk.yellow("Clipboard unavailable. Copy and run the command below:")); + process.stdout.write(cdCommand + "\n"); + } + } + catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Failed to resolve worktree:"), error.message); + } + else { + console.error(chalk.red("Failed to resolve worktree:"), error); + } + process.exit(1); + } +} diff --git a/build/index.js b/build/index.js index 587bff7..f77c4ba 100755 --- a/build/index.js +++ b/build/index.js @@ -10,6 +10,7 @@ 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"; const program = new Command(); program .name("wt") @@ -83,6 +84,11 @@ program .option("-e, --editor ", "Editor to use for opening the worktree (overrides default editor)") .description("Extract an existing branch as a new worktree. If no branch is specified, extracts the current branch.") .action(extractWorktreeHandler); +program + .command("cd") + .argument("[pathOrBranch]", "Path to worktree or branch name") + .description("Print the path of a worktree for use with cd. Usage: cd $(wt cd)") + .action(cdWorktreeHandler); program .command("config") .description("Manage CLI configuration settings.") diff --git a/src/commands/cd.ts b/src/commands/cd.ts new file mode 100644 index 0000000..b99e0d9 --- /dev/null +++ b/src/commands/cd.ts @@ -0,0 +1,112 @@ +import { execa } from "execa"; +import chalk from "chalk"; +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { findWorktreeByBranch, findWorktreeByPath, WorktreeInfo } from "../utils/git.js"; +import { selectWorktree } from "../utils/tui.js"; + +export async function cdWorktreeHandler(pathOrBranch: string = "") { + try { + await execa("git", ["rev-parse", "--is-inside-work-tree"]); + + let targetWorktree: WorktreeInfo | null = null; + + if (!pathOrBranch) { + const selected = await selectWorktree({ + message: "Select a worktree to navigate to", + excludeMain: false, + }); + + if (!selected || Array.isArray(selected)) { + console.error(chalk.yellow("No worktree selected.")); + process.exit(0); + } + + targetWorktree = selected; + } else { + // Try to find by path first + try { + const stats = await stat(pathOrBranch); + if (stats.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 { + console.error(chalk.red(`The path "${pathOrBranch}" exists but is not a git worktree.`)); + process.exit(1); + } + } + } + } catch { + // Not a valid path, try as 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 cd' without arguments to select interactively.")); + process.exit(1); + } + } + } + + const targetPath = targetWorktree.path; + + // 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.")); + process.exit(1); + } + + // Shell-escape the path by wrapping in single quotes + const escapedPath = "'" + targetPath.replace(/'/g, "'\\''") + "'"; + const cdCommand = `cd ${escapedPath}`; + + // Copy cd command to clipboard (cross-platform) + let clipboardCmd: string; + let clipboardArgs: string[]; + if (process.platform === "darwin") { + clipboardCmd = "pbcopy"; + clipboardArgs = []; + } else if (process.platform === "win32") { + clipboardCmd = "clip.exe"; + clipboardArgs = []; + } else { + clipboardCmd = "xclip"; + clipboardArgs = ["-selection", "clipboard"]; + } + + try { + await execa(clipboardCmd, clipboardArgs, { input: cdCommand }); + console.log(chalk.green(`Copied to clipboard: ${cdCommand}`)); + const pasteHint = process.platform === "darwin" ? "Cmd+V" : "Ctrl+V"; + console.log(chalk.gray(`Paste with ${pasteHint} and press Enter to navigate.`)); + } catch { + // Fallback if clipboard command is unavailable + console.error(chalk.yellow("Clipboard unavailable. Copy and run the command below:")); + process.stdout.write(cdCommand + "\n"); + } + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Failed to resolve worktree:"), error.message); + } else { + console.error(chalk.red("Failed to resolve worktree:"), error); + } + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index beaa0e9..21104e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ 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"; const program = new Command(); @@ -166,6 +167,12 @@ program ) .action(extractWorktreeHandler); +program + .command("cd") + .argument("[pathOrBranch]", "Path to worktree or branch name") + .description("Print the path of a worktree for use with cd. Usage: cd $(wt cd)") + .action(cdWorktreeHandler); + program .command("config") .description("Manage CLI configuration settings.")