diff --git a/README.md b/README.md index 7fdb921..9240c8c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,34 @@ pnpm install -g @johnlindquist/worktree ## Usage +### Shell Autocompletion (bash/zsh) + +Enable completion for the current shell session: + +```bash +eval "$(wt completion bash)" +``` + +```zsh +eval "$(wt completion zsh)" +``` + +To enable it permanently, add the matching line to your shell profile: + +```bash +echo 'eval "$(wt completion bash)"' >> ~/.bashrc +``` + +```zsh +echo 'eval "$(wt completion zsh)"' >> ~/.zshrc +``` + +Completion coverage includes: +- subcommands +- worktree branches for `merge`, `remove`/`rm`, and `open` +- git branches for `new`, `setup`, and `extract` +- `config` subcommands (`set`, `get`, `clear`, `path`) + ### Create a new worktree from Branch Name ```bash diff --git a/build/commands/completion.js b/build/commands/completion.js new file mode 100644 index 0000000..081632a --- /dev/null +++ b/build/commands/completion.js @@ -0,0 +1,98 @@ +import { bashCompletionScript } from "../completions/bash.js"; +import { zshCompletionScript } from "../completions/zsh.js"; +import { getWorktrees, getBranches } from "../utils/git.js"; +const SUBCOMMANDS = [ + "new", + "setup", + "list", + "ls", + "remove", + "rm", + "merge", + "purge", + "pr", + "open", + "extract", + "config", + "completion", +]; +const CONFIG_SUBCOMMANDS = ["set", "get", "clear", "path"]; +const WORKTREE_BRANCH_COMMANDS = new Set(["merge", "remove", "rm", "open"]); +const GIT_BRANCH_COMMANDS = new Set(["new", "setup", "extract"]); +/** + * Handle the `wt completion [shell]` command. + * Outputs the shell completion script to stdout. + */ +export async function completionHandler(shell) { + switch (shell) { + case "bash": + console.log(bashCompletionScript()); + break; + case "zsh": + console.log(zshCompletionScript()); + break; + default: + console.error(`Unsupported shell: ${shell}. Supported shells: bash, zsh`); + process.exit(1); + } +} +/** + * Handle dynamic completion requests from the shell completion script. + * + * Called internally as `wt __complete -- ` where words are the + * current command-line tokens (excluding the program name itself). + * + * Outputs one candidate per line to stdout. + */ +export async function getCompletions(words) { + try { + // Current word being typed (last element, may be empty string) + const current = words.length > 0 ? words[words.length - 1] : ""; + // Completed words before the current one + const completed = words.slice(0, -1); + // No completed words → completing the subcommand itself + if (completed.length === 0) { + const matches = SUBCOMMANDS.filter((cmd) => cmd.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + const command = completed[0]; + // Worktree branch commands: merge, remove/rm, open + if (WORKTREE_BRANCH_COMMANDS.has(command)) { + const worktrees = await getWorktrees(); + const branches = worktrees + .filter((wt) => !wt.isMain && wt.branch) + .map((wt) => wt.branch); + const matches = branches.filter((b) => b.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + // Git branch commands: new, setup, extract + if (GIT_BRANCH_COMMANDS.has(command)) { + const branches = await getBranches(); + const matches = branches.filter((b) => b.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + // Config subcommands + if (command === "config") { + if (completed.length === 1) { + const matches = CONFIG_SUBCOMMANDS.filter((cmd) => cmd.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + } + return; + } + // No completions for other commands (pr, purge, list, etc.) + } + catch { + // Silently return empty results on error + } +} diff --git a/build/completions/bash.js b/build/completions/bash.js new file mode 100644 index 0000000..85d4f49 --- /dev/null +++ b/build/completions/bash.js @@ -0,0 +1,21 @@ +/** + * Generate a Bash completion script for the `wt` CLI. + * + * The script registers a completion function that calls `wt __complete` + * to obtain dynamic completion candidates whenever the user presses Tab. + */ +export function bashCompletionScript() { + return ` +_wt_completions() { + local cur="\${COMP_WORDS[COMP_CWORD]}" + local words=("\${COMP_WORDS[@]:1}") + + local completions + completions=$(wt __complete -- "\${words[@]}" 2>/dev/null) + + COMPREPLY=($(compgen -W "\${completions}" -- "\${cur}")) +} + +complete -F _wt_completions wt +`.trimStart(); +} diff --git a/build/completions/zsh.js b/build/completions/zsh.js new file mode 100644 index 0000000..e242238 --- /dev/null +++ b/build/completions/zsh.js @@ -0,0 +1,24 @@ +/** + * Generate a Zsh completion script for the `wt` CLI. + * + * The script defines a completion function that calls `wt __complete` + * to obtain dynamic completion candidates whenever the user presses Tab. + */ +export function zshCompletionScript() { + return ` +#compdef wt + +_wt() { + local -a completions + local words_arr=("\${words[@]:1}") + + completions=(\${(f)"$(wt __complete -- "\${words_arr[@]}" 2>/dev/null)"}) + + if (( \${#completions} > 0 )); then + compadd -a completions + fi +} + +compdef _wt wt +`.trimStart(); +} diff --git a/build/index.js b/build/index.js index 587bff7..ae3d5f2 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 { completionHandler, getCompletions } from "./commands/completion.js"; const program = new Command(); program .name("wt") @@ -119,4 +120,18 @@ program .addCommand(new Command("path") .description("Show the path to the configuration file.") .action(() => configHandler("path"))); +program + .command("completion") + .argument("[shell]", "Shell type (bash, zsh)", "bash") + .description('Output shell completion script. Run: eval "$(wt completion bash)"') + .action(completionHandler); +program + .command("__complete", { hidden: true }) + .allowUnknownOption() + .allowExcessArguments(true) + .action(async () => { + const dashIndex = process.argv.indexOf("--"); + const words = dashIndex >= 0 ? process.argv.slice(dashIndex + 1) : []; + await getCompletions(words); +}); program.parse(process.argv); diff --git a/build/utils/git.js b/build/utils/git.js index 01dc6db..0063087 100644 --- a/build/utils/git.js +++ b/build/utils/git.js @@ -456,3 +456,21 @@ export async function popStash(cwd = ".") { return false; } } +/** + * Get the list of local branch names + * + * @param cwd - Working directory to run git command from + * @returns Array of local branch names (short form) + */ +export async function getBranches(cwd = ".") { + try { + const { stdout } = await execa("git", ["-C", cwd, "branch", "--format=%(refname:short)"]); + if (!stdout.trim()) { + return []; + } + return stdout.split("\n").map(b => b.trim()).filter(b => b); + } + catch { + return []; + } +} diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..df5e2c4 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,114 @@ +import { bashCompletionScript } from "../completions/bash.js"; +import { zshCompletionScript } from "../completions/zsh.js"; +import { getWorktrees, getBranches } from "../utils/git.js"; + +const SUBCOMMANDS = [ + "new", + "setup", + "list", + "ls", + "remove", + "rm", + "merge", + "purge", + "pr", + "open", + "extract", + "config", + "completion", +]; + +const CONFIG_SUBCOMMANDS = ["set", "get", "clear", "path"]; + +const WORKTREE_BRANCH_COMMANDS = new Set(["merge", "remove", "rm", "open"]); +const GIT_BRANCH_COMMANDS = new Set(["new", "setup", "extract"]); + +/** + * Handle the `wt completion [shell]` command. + * Outputs the shell completion script to stdout. + */ +export async function completionHandler(shell: string): Promise { + switch (shell) { + case "bash": + console.log(bashCompletionScript()); + break; + case "zsh": + console.log(zshCompletionScript()); + break; + default: + console.error( + `Unsupported shell: ${shell}. Supported shells: bash, zsh` + ); + process.exit(1); + } +} + +/** + * Handle dynamic completion requests from the shell completion script. + * + * Called internally as `wt __complete -- ` where words are the + * current command-line tokens (excluding the program name itself). + * + * Outputs one candidate per line to stdout. + */ +export async function getCompletions(words: string[]): Promise { + try { + // Current word being typed (last element, may be empty string) + const current = words.length > 0 ? words[words.length - 1] : ""; + // Completed words before the current one + const completed = words.slice(0, -1); + + // No completed words → completing the subcommand itself + if (completed.length === 0) { + const matches = SUBCOMMANDS.filter((cmd) => + cmd.startsWith(current) + ); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + + const command = completed[0]; + + // Worktree branch commands: merge, remove/rm, open + if (WORKTREE_BRANCH_COMMANDS.has(command)) { + const worktrees = await getWorktrees(); + const branches = worktrees + .filter((wt) => !wt.isMain && wt.branch) + .map((wt) => wt.branch as string); + const matches = branches.filter((b) => b.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + + // Git branch commands: new, setup, extract + if (GIT_BRANCH_COMMANDS.has(command)) { + const branches = await getBranches(); + const matches = branches.filter((b) => b.startsWith(current)); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + return; + } + + // Config subcommands + if (command === "config") { + if (completed.length === 1) { + const matches = CONFIG_SUBCOMMANDS.filter((cmd) => + cmd.startsWith(current) + ); + if (matches.length > 0) { + console.log(matches.join("\n")); + } + } + return; + } + + // No completions for other commands (pr, purge, list, etc.) + } catch { + // Silently return empty results on error + } +} diff --git a/src/completions/bash.ts b/src/completions/bash.ts new file mode 100644 index 0000000..c947544 --- /dev/null +++ b/src/completions/bash.ts @@ -0,0 +1,21 @@ +/** + * Generate a Bash completion script for the `wt` CLI. + * + * The script registers a completion function that calls `wt __complete` + * to obtain dynamic completion candidates whenever the user presses Tab. + */ +export function bashCompletionScript(): string { + return ` +_wt_completions() { + local cur="\${COMP_WORDS[COMP_CWORD]}" + local words=("\${COMP_WORDS[@]:1}") + + local completions + completions=$(wt __complete -- "\${words[@]}" 2>/dev/null) + + COMPREPLY=($(compgen -W "\${completions}" -- "\${cur}")) +} + +complete -F _wt_completions wt +`.trimStart(); +} diff --git a/src/completions/zsh.ts b/src/completions/zsh.ts new file mode 100644 index 0000000..3ad960d --- /dev/null +++ b/src/completions/zsh.ts @@ -0,0 +1,24 @@ +/** + * Generate a Zsh completion script for the `wt` CLI. + * + * The script defines a completion function that calls `wt __complete` + * to obtain dynamic completion candidates whenever the user presses Tab. + */ +export function zshCompletionScript(): string { + return ` +#compdef wt + +_wt() { + local -a completions + local words_arr=("\${words[@]:1}") + + completions=(\${(f)"$(wt __complete -- "\${words_arr[@]}" 2>/dev/null)"}) + + if (( \${#completions} > 0 )); then + compadd -a completions + fi +} + +compdef _wt wt +`.trimStart(); +} diff --git a/src/index.ts b/src/index.ts index beaa0e9..3a5a227 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 { completionHandler, getCompletions } from "./commands/completion.js"; const program = new Command(); @@ -234,4 +235,22 @@ program .action(() => configHandler("path")) ); +program + .command("completion") + .argument("[shell]", "Shell type (bash, zsh)", "bash") + .description( + 'Output shell completion script. Run: eval "$(wt completion bash)"' + ) + .action(completionHandler); + +program + .command("__complete", { hidden: true }) + .allowUnknownOption() + .allowExcessArguments(true) + .action(async () => { + const dashIndex = process.argv.indexOf("--"); + const words = dashIndex >= 0 ? process.argv.slice(dashIndex + 1) : []; + await getCompletions(words); + }); + program.parse(process.argv); diff --git a/src/utils/git.ts b/src/utils/git.ts index 5e237cb..f458c95 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -502,4 +502,22 @@ export async function popStash(cwd: string = "."): Promise { console.error(chalk.red("Failed to pop stash:"), error.stderr || error.message); return false; } +} + +/** + * Get the list of local branch names + * + * @param cwd - Working directory to run git command from + * @returns Array of local branch names (short form) + */ +export async function getBranches(cwd: string = "."): Promise { + try { + const { stdout } = await execa("git", ["-C", cwd, "branch", "--format=%(refname:short)"]); + if (!stdout.trim()) { + return []; + } + return stdout.split("\n").map(b => b.trim()).filter(b => b); + } catch { + return []; + } } \ No newline at end of file diff --git a/test/completion.test.ts b/test/completion.test.ts new file mode 100644 index 0000000..59481c5 --- /dev/null +++ b/test/completion.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { WorktreeInfo } from '../src/utils/git.js'; + +const mockWorktrees: WorktreeInfo[] = [ + { + path: '/Users/test/repo', + head: 'abc123', + branch: 'main', + detached: false, + locked: false, + prunable: false, + isMain: true, + bare: false, + }, + { + path: '/Users/test/worktrees/feature-auth', + head: 'def456', + branch: 'feature/auth', + detached: false, + locked: false, + prunable: false, + isMain: false, + bare: false, + }, + { + path: '/Users/test/worktrees/bugfix', + head: 'ghi789', + branch: 'bugfix/issue-123', + detached: false, + locked: true, + lockReason: 'in use', + prunable: false, + isMain: false, + bare: false, + }, +]; + +const mockBranches = ['main', 'feature/auth', 'bugfix/issue-123', 'develop']; + +vi.mock('../src/utils/git.js', async () => { + return { + getWorktrees: vi.fn(async () => mockWorktrees), + getBranches: vi.fn(async () => mockBranches), + }; +}); + +const { getCompletions } = await import('../src/commands/completion.js'); + +/** Capture stdout output from getCompletions */ +async function captureCompletions(words: string[]): Promise { + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: any[]) => { + const output = args.join(' '); + lines.push(...output.split('\n')); + }; + try { + await getCompletions(words); + } finally { + console.log = originalLog; + } + return lines.filter(l => l !== ''); +} + +describe('Completion', () => { + describe('subcommand completion', () => { + it('should return all subcommands for empty input', async () => { + const result = await captureCompletions(['']); + expect(result).toContain('new'); + expect(result).toContain('merge'); + expect(result).toContain('remove'); + expect(result).toContain('rm'); + expect(result).toContain('open'); + expect(result).toContain('list'); + expect(result).toContain('ls'); + expect(result).toContain('config'); + expect(result).toContain('completion'); + expect(result).toContain('pr'); + expect(result).toContain('setup'); + expect(result).toContain('extract'); + expect(result).toContain('purge'); + }); + + it('should filter subcommands by prefix', async () => { + const result = await captureCompletions(['mer']); + expect(result).toEqual(['merge']); + }); + + it('should match multiple subcommands with shared prefix', async () => { + const result = await captureCompletions(['l']); + expect(result).toContain('list'); + expect(result).toContain('ls'); + expect(result).toHaveLength(2); + }); + + it('should return empty for non-matching prefix', async () => { + const result = await captureCompletions(['xyz']); + expect(result).toHaveLength(0); + }); + }); + + describe('worktree branch completion (merge/remove/rm/open)', () => { + it('should return non-main worktree branches for merge', async () => { + const result = await captureCompletions(['merge', '']); + expect(result).toContain('feature/auth'); + expect(result).toContain('bugfix/issue-123'); + expect(result).not.toContain('main'); + }); + + it('should return non-main worktree branches for rm', async () => { + const result = await captureCompletions(['rm', '']); + expect(result).toContain('feature/auth'); + expect(result).toContain('bugfix/issue-123'); + expect(result).not.toContain('main'); + }); + + it('should return non-main worktree branches for remove', async () => { + const result = await captureCompletions(['remove', '']); + expect(result).toContain('feature/auth'); + expect(result).toContain('bugfix/issue-123'); + }); + + it('should return non-main worktree branches for open', async () => { + const result = await captureCompletions(['open', '']); + expect(result).toContain('feature/auth'); + expect(result).toContain('bugfix/issue-123'); + }); + + it('should filter worktree branches by prefix', async () => { + const result = await captureCompletions(['merge', 'feature']); + expect(result).toEqual(['feature/auth']); + }); + }); + + describe('git branch completion (new/setup/extract)', () => { + it('should return all git branches for new', async () => { + const result = await captureCompletions(['new', '']); + expect(result).toContain('main'); + expect(result).toContain('feature/auth'); + expect(result).toContain('bugfix/issue-123'); + expect(result).toContain('develop'); + }); + + it('should return all git branches for setup', async () => { + const result = await captureCompletions(['setup', '']); + expect(result).toContain('main'); + expect(result).toContain('develop'); + }); + + it('should return all git branches for extract', async () => { + const result = await captureCompletions(['extract', '']); + expect(result).toContain('main'); + expect(result).toContain('develop'); + }); + + it('should filter git branches by prefix', async () => { + const result = await captureCompletions(['new', 'dev']); + expect(result).toEqual(['develop']); + }); + }); + + describe('config subcommand completion', () => { + it('should return config subcommands', async () => { + const result = await captureCompletions(['config', '']); + expect(result).toContain('set'); + expect(result).toContain('get'); + expect(result).toContain('clear'); + expect(result).toContain('path'); + }); + + it('should filter config subcommands by prefix', async () => { + const result = await captureCompletions(['config', 'se']); + expect(result).toEqual(['set']); + }); + + it('should not complete beyond config subcommands', async () => { + const result = await captureCompletions(['config', 'set', '']); + expect(result).toHaveLength(0); + }); + }); + + describe('commands with no dynamic completion', () => { + it('should return empty for pr arguments', async () => { + const result = await captureCompletions(['pr', '']); + expect(result).toHaveLength(0); + }); + + it('should return empty for list arguments', async () => { + const result = await captureCompletions(['list', '']); + expect(result).toHaveLength(0); + }); + + it('should return empty for purge arguments', async () => { + const result = await captureCompletions(['purge', '']); + expect(result).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should return all subcommands for empty words array', async () => { + const result = await captureCompletions([]); + expect(result).toContain('new'); + expect(result).toContain('merge'); + expect(result).toContain('open'); + expect(result).toHaveLength(13); + }); + }); +});