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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,53 @@ wt open [pathOrBranch]

**Interactive Selection**: Run `wt open` without arguments to see a fuzzy-searchable list of worktrees.

Options:
- `-e, --editor <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
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
Expand Down
118 changes: 118 additions & 0 deletions build/commands/cd.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
108 changes: 108 additions & 0 deletions build/commands/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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");
// 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");
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");
}
Loading