Skip to content
Open
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
110 changes: 110 additions & 0 deletions build/commands/cd.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -83,6 +84,11 @@ program
.option("-e, --editor <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.")
Expand Down
112 changes: 112 additions & 0 deletions src/commands/cd.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Comment on lines +76 to +78
Copy link

@coderabbitai coderabbitai bot Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd src/commands && head -n 95 cd.ts | tail -n 35

Repository: johnlindquist/worktree-cli

Length of output: 1281


🏁 Script executed:

cd src/commands && sed -n '86,88p' cd.ts

Repository: johnlindquist/worktree-cli

Length of output: 191


🏁 Script executed:

cd src/commands && sed -n '78,95p' cd.ts

Repository: johnlindquist/worktree-cli

Length of output: 692


🏁 Script executed:

cd src/commands && wc -l cd.ts && cat -n cd.ts

Repository: johnlindquist/worktree-cli

Length of output: 5286


🌐 Web query:

Windows cmd.exe cd command single quotes path escaping behavior

💡 Result:

In cmd.exe, single quotes (') have no special “string quoting” meaning for commands like cd. They’re treated as literal characters, so:

  • cd 'C:\Temp' tries to change into a directory literally named 'C:\Temp' (including the apostrophes) and will typically fail. [2], [3]

To handle spaces / special characters in a path in cmd.exe, use double quotes:

  • cd /d "C:\Program Files\My App" (use /d to also switch drive letters) [1]
  • In general, “single quotation marks won’t do … you have to add quotation marks around each path” in cmd.exe. [3]

Escaping note (cmd parser):

  • cmd.exe’s escape character is caret ^ for metacharacters like & | < > ( ), but it does not turn '...' into a quoted string. [1], [2]

Bottom line: for cd, don’t use single quotes; use double quotes around the path. [1], [2]

Sources:
[1] Microsoft Learn — cmd command documentation (quoting/escaping rules) (learn.microsoft.com)
[2] Stack Overflow — single quotes in batch/cmd; only double quotes are standard (stackoverflow.com)
[3] Stack Overflow — “Single quotation marks won’t do… add quotation marks around each path” (stackoverflow.com)

Citations:


🌐 Web query:

PowerShell cd command single quotes path escaping behavior Windows

💡 Result:

In Windows PowerShell / PowerShell on Windows, cd is an alias of Set-Location. Single quotes affect PowerShell’s string parsing, not path wildcard (globbing) behavior.

  • Single-quoted strings are verbatim. Nothing is expanded (no $var, no subexpressions), and the backtick ` is treated as a literal character. To embed a literal single-quote inside a single-quoted string, double it ('').[1]
    Examples:

    cd 'C:\Program Files'          # spaces are fine
    cd 'C:\Client''s Files'        # apostrophe escaped by doubling
  • Quoting does not disable wildcard interpretation for -Path. Set-Location -Path can treat wildcard metacharacters (notably * ? [ ]) as wildcards even if the path is in quotes. If you need the path interpreted literally, use -LiteralPath.[2]
    Example (folder name contains brackets):

    cd -LiteralPath 'C:\Test\[Hello]'
  • If you must use -Path with literal wildcard characters, you can escape wildcard metacharacters with the backtick (PowerShell’s escape character), but -LiteralPath is the simplest/most reliable option.[2][3]

Sources: about_Quoting_Rules[1], Set-Location (-LiteralPath semantics and why single quotes help when escape characters are present)[2], Microsoft Learn guidance on escaping wildcard metacharacters with backtick[3].

[1] (learn.microsoft.com)
[2] (learn.microsoft.com)
[3] (learn.microsoft.com)

Citations:


Adapt shell-escaping strategy to platform-specific requirements.

The current POSIX single-quote escaping (lines 76–77) fails on Windows cmd.exe because single quotes have no special string-quoting meaning—they are treated as literal characters. For cmd.exe, paths require double quotes; for PowerShell, single quotes work but apostrophes must be doubled (''), not escaped with backslash (\'). Since the code already detects the platform for clipboard commands (lines 86–88), apply the same logic to generate correct escaping for each shell:

  • cmd.exe: Use double quotes with /d flag (e.g., cd /d "C:\path with spaces")
  • PowerShell: Use single quotes with '' for apostrophes (e.g., cd 'C:\path with''s files')
  • POSIX (macOS, Linux): Keep current single-quote escaping
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/cd.ts` around lines 76 - 78, The current POSIX-only escaping
using escapedPath and cdCommand must be made platform/shell-aware: detect the
target shell/platform (use the same detection used for clipboard logic) and
build cdCommand accordingly—on Windows cmd.exe produce cd /d "path" using double
quotes and escaping any internal double quotes, for PowerShell produce cd 'path'
but escape apostrophes by doubling them (replace ' with ''), and for POSIX keep
the existing single-quote strategy (escapedPath = "'" + path.replace(/'/g,
"'\\''") + "'" and cdCommand = `cd ${escapedPath}`). Update the code that
currently defines escapedPath and cdCommand in src/commands/cd.ts to branch by
shell type and construct the correct command string.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the current implementation of shell-escaping in src/commands/cd.ts
cat src/commands/cd.ts | sed -n '70,111p'

Length of output: 1795


@imayolas, I can see the code still uses the POSIX-only single-quote escaping approach on lines 76-78:

const escapedPath = "'" + targetPath.replace(/'/g, "'\\''") + "'";
const cdCommand = `cd ${escapedPath}`;

The platform-specific escaping logic hasn't been added yet. The code needs to branch based on the shell/platform (similar to how it detects the platform for clipboard operations on lines 81-90) to generate the correct cdCommand:

  • Windows cmd.exe: cd /d "C:\path" (double quotes)
  • PowerShell: cd 'C:\path''s' (single quotes with doubled apostrophes)
  • POSIX: Current approach (single quotes with '\'' escape)

Have you pushed the changes yet, or would you like help implementing the platform-specific escaping logic?


🧠 Learnings used
Learnt from: CR
Repo: johnlindquist/worktree-cli PR: 0
File: .cursor/rules/project.mdc:0-0
Timestamp: 2025-08-04T13:02:29.847Z
Learning: Applies to src/commands/list.ts : Provide functionality to list existing Git worktrees in src/commands/list.ts.

Learnt from: CR
Repo: johnlindquist/worktree-cli PR: 0
File: .cursor/rules/project.mdc:0-0
Timestamp: 2025-08-04T13:02:29.847Z
Learning: Applies to src/commands/new.ts : Implement logic for creating new Git worktrees, including options for branch creation, dependency installation, and opening in an editor, in src/commands/new.ts.

Learnt from: CR
Repo: johnlindquist/worktree-cli PR: 0
File: .cursor/rules/project.mdc:0-0
Timestamp: 2025-08-04T13:02:29.847Z
Learning: Applies to src/commands/*.ts : Leverage Execa to execute Git commands and other external processes.

Learnt from: CR
Repo: johnlindquist/worktree-cli PR: 0
File: .cursor/rules/project.mdc:0-0
Timestamp: 2025-08-04T13:02:29.847Z
Learning: Applies to src/commands/remove.ts : Handle removal of Git worktrees, including support for force deletion, in src/commands/remove.ts.

Learnt from: juristr
Repo: johnlindquist/worktree-cli PR: 20
File: src/commands/extract.ts:124-127
Timestamp: 2025-08-04T14:22:29.156Z
Learning: The worktree-cli project prioritizes consistency across commands. When implementing new commands like `extract`, developers follow existing patterns from similar commands like `new` to maintain API and implementation consistency.


// 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);
}
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.")
Expand Down