From 89184d0d2a1e1af36ce42e38012f312e72912cd9 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 16 May 2026 13:38:36 -0700 Subject: [PATCH] Fix performance on tab completion --- src/cli.ts | 72 +++++++------ src/commands/completion.ts | 165 +++++++++++++++++++++++------ src/core/shell.ts | 75 +++++++++++-- test/cli/shell.integration.test.ts | 22 ++++ 4 files changed, 259 insertions(+), 75 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index af5e960..9e561c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,39 +1,45 @@ -import { Command } from 'commander'; +if (process.argv[2] === '__complete') { + const { runCompletion } = await import('@/commands/completion'); + await runCompletion(process.argv.slice(3)); +} else { + const { Command } = await import('commander'); + const { registerCloneCommand } = await import('@/commands/clone'); + const { registerCompletionCommand } = await import('@/commands/completion'); + const { registerHelpCommand } = await import('@/commands/help'); + const { registerInitCommand } = await import('@/commands/init'); + const { registerListCommand } = await import('@/commands/list'); + const { registerPrCommand } = await import('@/commands/pr'); + const { registerRemoveCommand } = await import('@/commands/remove'); + const { registerSetupCommand } = await import('@/commands/setup'); + const { registerShellInitCommand } = await import('@/commands/shell-init'); + const { registerSwitchCommand } = await import('@/commands/switch'); + const { readPackageVersion } = await import('@/core/package-version'); -import { registerCloneCommand } from '@/commands/clone'; -import { registerCompletionCommand } from '@/commands/completion'; -import { registerHelpCommand } from '@/commands/help'; -import { registerInitCommand } from '@/commands/init'; -import { registerListCommand } from '@/commands/list'; -import { registerPrCommand } from '@/commands/pr'; -import { registerRemoveCommand } from '@/commands/remove'; -import { registerSetupCommand } from '@/commands/setup'; -import { registerShellInitCommand } from '@/commands/shell-init'; -import { registerSwitchCommand } from '@/commands/switch'; -import { readPackageVersion } from '@/core/package-version'; + const program = new Command(); + const packageVersion = await readPackageVersion(); -const program = new Command(); -const packageVersion = await readPackageVersion(); + program + .name('gw') + .description('Manage git worktree projects') + .version(packageVersion, '-v, --version') + .showHelpAfterError(); -program - .name('gw') - .description('Manage git worktree projects') - .version(packageVersion, '-v, --version') - .showHelpAfterError(); + registerListCommand(program); + registerSwitchCommand(program); + registerPrCommand(program); + registerRemoveCommand(program); + registerCloneCommand(program); + registerInitCommand(program); + registerCompletionCommand(program); + registerShellInitCommand(program); + registerSetupCommand(program); + registerHelpCommand(program); -registerListCommand(program); -registerSwitchCommand(program); -registerPrCommand(program); -registerRemoveCommand(program); -registerCloneCommand(program); -registerInitCommand(program); -registerCompletionCommand(program); -registerShellInitCommand(program); -registerSetupCommand(program); -registerHelpCommand(program); + program.action(() => { + program.outputHelp(); + }); -program.action(() => { - program.outputHelp(); -}); + await program.parseAsync(process.argv); +} -await program.parseAsync(process.argv); +export {}; diff --git a/src/commands/completion.ts b/src/commands/completion.ts index 2f0c70c..e5e892a 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -1,14 +1,24 @@ import type { Command } from 'commander'; +import { execFile } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; -import { loadProjectContext } from '@/commands/shared'; import { buildCompletionCandidates, needsWorktreeCompletion, normalizeCompletionWords, type CompletionCandidate, } from '@/core/completion'; -import { getBranchPrefix, getPrimaryBranch } from '@/core/config'; -import { listWorktrees } from '@/core/worktrees'; +import { + getBranchPrefix, + getGwConfigPath, + getPrimaryBranch, + parseGwConfig, +} from '@/core/config'; +import { type WorktreeEntry } from '@/core/worktrees'; + +const execFileAsync = promisify(execFile); function normalizeVariadicWords( words: string[] | string | undefined @@ -24,6 +34,99 @@ function normalizeVariadicWords( return []; } +function normalizeRawCompletionWords(rawWords: string[]): string[] { + return rawWords[0] === '--' ? rawWords.slice(1) : rawWords; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function findCompletionProjectRoot( + startPath = process.cwd() +): Promise { + let dir = resolve(startPath); + + while (true) { + if (await fileExists(getGwConfigPath(dir))) { + return dir; + } + + const parentDir = dirname(dir); + if (parentDir === dir) { + return null; + } + + dir = parentDir; + } +} + +function parseCompletionWorktrees(output: string): WorktreeEntry[] { + const worktrees: WorktreeEntry[] = []; + let path: string | undefined; + let branchName: string | undefined; + + const flush = () => { + if (path && branchName) { + worktrees.push({ path, branchName }); + } + }; + + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.startsWith('worktree ')) { + flush(); + path = line.slice('worktree '.length); + branchName = undefined; + continue; + } + + if (line.startsWith('branch refs/heads/')) { + branchName = line.slice('branch refs/heads/'.length); + } + } + + flush(); + return worktrees; +} + +async function listCompletionWorktrees( + repoPath: string +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['worktree', 'list', '--porcelain'], + { + cwd: repoPath, + } + ); + + return parseCompletionWorktrees(stdout); +} + +async function loadCompletionData() { + const projectRoot = await findCompletionProjectRoot(); + if (!projectRoot) { + return undefined; + } + + const config = parseGwConfig( + await readFile(getGwConfigPath(projectRoot), 'utf8') + ); + const primaryBranch = getPrimaryBranch(config, projectRoot); + + return { + branchPrefix: getBranchPrefix(config), + primaryBranch, + worktrees: await listCompletionWorktrees(join(projectRoot, primaryBranch)), + }; +} + function sanitizeCompletionField(value: string): string { return value.replace(/[\t\r\n]/gu, ' '); } @@ -37,6 +140,30 @@ function formatCompletionCandidate(candidate: CompletionCandidate): string { return description ? `${value}\t${description}` : value; } +export async function runCompletion(rawWords: string[]): Promise { + try { + const completedWords = normalizeCompletionWords( + normalizeRawCompletionWords(rawWords) + ); + const request = { + completedWords, + current: process.env.GW_COMPLETE_CURRENT || '', + }; + const data = needsWorktreeCompletion(request) + ? await loadCompletionData() + : undefined; + const candidates = buildCompletionCandidates(request, data); + + if (candidates.length > 0) { + process.stdout.write( + `${candidates.map(formatCompletionCandidate).join('\n')}\n` + ); + } + } catch { + // Completion should never interrupt the user's prompt. + } +} + export function registerCompletionCommand(program: Command): void { program .command('__complete', { hidden: true }) @@ -44,36 +171,6 @@ export function registerCompletionCommand(program: Command): void { .allowExcessArguments() .argument('[words...]') .action(async (words: string[] | string | undefined) => { - try { - const completedWords = normalizeCompletionWords( - normalizeVariadicWords(words) - ); - const request = { - completedWords, - current: process.env.GW_COMPLETE_CURRENT || '', - }; - const data = needsWorktreeCompletion(request) - ? await (async () => { - const context = await loadProjectContext(); - return { - branchPrefix: getBranchPrefix(context.config), - primaryBranch: getPrimaryBranch( - context.config, - context.projectRoot - ), - worktrees: await listWorktrees(context.anchorRepo), - }; - })() - : undefined; - const candidates = buildCompletionCandidates(request, data); - - if (candidates.length > 0) { - process.stdout.write( - `${candidates.map(formatCompletionCandidate).join('\n')}\n` - ); - } - } catch { - // Completion should never interrupt the user's prompt. - } + await runCompletion(normalizeVariadicWords(words)); }); } diff --git a/src/core/shell.ts b/src/core/shell.ts index 4decfee..9fd59d7 100644 --- a/src/core/shell.ts +++ b/src/core/shell.ts @@ -319,25 +319,51 @@ function renderBashLikeShellWrapper(): string { fi rm -f "$_gw_cwd_file" "$_gw_source_file" + __gw_completion_cache_key= return $_gw_status } `; } +function renderBashLikeCompletionCache(): string { + return `__gw_completion_cache_key= +__gw_completion_cache_output= + +__gw_completion_query() { + local _gw_query_current="$1" + shift + local _gw_key="$PWD|$_gw_query_current|$*" + + if [[ "$_gw_key" != "$__gw_completion_cache_key" ]]; then + __gw_completion_cache_output=$(GW_COMPLETE_CURRENT="$_gw_query_current" command gw __complete -- "$@") + __gw_completion_cache_key="$_gw_key" + fi + + printf '%s\n' "$__gw_completion_cache_output" +} +`; +} + function renderBashCompletionInit(): string { return `__gw_bash_complete() { local _gw_current _gw_value _gw_description + local _gw_query_current local -a _gw_words_before_current COMPREPLY=() _gw_current="\${COMP_WORDS[COMP_CWORD]}" _gw_words_before_current=("\${COMP_WORDS[@]:0:COMP_CWORD}") + if [[ "$_gw_current" == -* ]]; then + _gw_query_current="-" + else + _gw_query_current="" + fi while IFS=$'\t' read -r _gw_value _gw_description; do - if [[ -n "$_gw_value" ]]; then + if [[ -n "$_gw_value" && "$_gw_value" == "$_gw_current"* ]]; then COMPREPLY+=("$_gw_value") fi - done < <(GW_COMPLETE_CURRENT="$_gw_current" command gw __complete -- "\${_gw_words_before_current[@]}") + done < <(__gw_completion_query "$_gw_query_current" "\${_gw_words_before_current[@]}") } complete -F __gw_bash_complete gw @@ -347,19 +373,25 @@ complete -F __gw_bash_complete gw function renderZshCompletionInit(): string { return `__gw_zsh_complete() { local _gw_current _gw_value _gw_description + local _gw_query_current local -a _gw_words_before_current _gw_values _gw_descriptions _gw_current="\${words[CURRENT]:-}" if (( CURRENT > 1 )); then _gw_words_before_current=("\${(@)words[1,$(( CURRENT - 1 ))]}") fi + if [[ "$_gw_current" == -* ]]; then + _gw_query_current="-" + else + _gw_query_current="" + fi while IFS=$'\t' read -r _gw_value _gw_description; do - if [[ -n "$_gw_value" ]]; then + if [[ -n "$_gw_value" && "$_gw_value" == "$_gw_current"* ]]; then _gw_values+=("$_gw_value") _gw_descriptions+=("\${_gw_description:-$_gw_value}") fi - done < <(GW_COMPLETE_CURRENT="$_gw_current" command gw __complete -- "\${_gw_words_before_current[@]}") + done < <(__gw_completion_query "$_gw_query_current" "\${_gw_words_before_current[@]}") if (( \${#_gw_values[@]} > 0 )); then compadd -d _gw_descriptions -- "\${_gw_values[@]}" @@ -377,23 +409,49 @@ fi } function renderBashShellInit(): string { - return `${renderBashLikeShellWrapper()}${renderBashCompletionInit()}`; + return `${renderBashLikeShellWrapper()}${renderBashLikeCompletionCache()}${renderBashCompletionInit()}`; } function renderZshShellInit(): string { - return `${renderBashLikeShellWrapper()}${renderZshCompletionInit()}`; + return `${renderBashLikeShellWrapper()}${renderBashLikeCompletionCache()}${renderZshCompletionInit()}`; } function renderFishCompletionInit(): string { - return `function __gw_fish_complete + return `set -g __gw_completion_cache_key +set -g __gw_completion_cache_output + +function __gw_fish_completion_query + set -l query_current $argv[1] + set -e argv[1] + set -l key (string join \\t -- $PWD $query_current $argv) + + if test "$key" != "$__gw_completion_cache_key" + set -g __gw_completion_cache_output (env GW_COMPLETE_CURRENT="$query_current" command gw __complete -- $argv) + set -g __gw_completion_cache_key "$key" + end + + printf '%s\n' $__gw_completion_cache_output +end + +function __gw_fish_complete set -l current (commandline -ct) set -l tokens (commandline -opc) + set -l query_current '' if test -n "$current"; and test (count $tokens) -gt 0; and test "$tokens[-1]" = "$current" set -e tokens[-1] end - env GW_COMPLETE_CURRENT="$current" command gw __complete -- $tokens + if string match -q -- '-*' "$current" + set query_current '-' + end + + for candidate in (__gw_fish_completion_query "$query_current" $tokens) + set -l value (string split -m1 \\t -- $candidate)[1] + if string match -q -- "$current*" "$value" + printf '%s\n' "$candidate" + end + end end complete -c gw -f -a '(__gw_fish_complete)' @@ -441,6 +499,7 @@ function renderFishShellInit(): string { end command rm -f -- "$cwd_file" "$source_file" + set -g __gw_completion_cache_key return $_gw_status end ${renderFishCompletionInit()} diff --git a/test/cli/shell.integration.test.ts b/test/cli/shell.integration.test.ts index 0fca7a6..31db50a 100644 --- a/test/cli/shell.integration.test.ts +++ b/test/cli/shell.integration.test.ts @@ -219,6 +219,28 @@ describe('shell wrapper integration', () => { 60_000 ); + (hasFish ? test : test.skip)( + 'supports partial fish completion matches', + async () => { + const fixture = await createShellFixture(); + + const result = await execa( + 'fish', + [ + '--no-config', + '-c', + 'gw shell-init | source; cd $argv[1]; gw clone demo $argv[2] >/dev/null 2>/dev/null; cd $argv[1]/demo/main; complete -C "gw switch m" | string match -q "main*"; or exit 1; printf OK', + fixture.workDir, + fixture.originPath, + ], + { env: fixture.env } + ); + + expect(result.stdout).toContain('OK'); + }, + 60_000 + ); + (hasNu ? test : test.skip)( 'supports the nu wrapper flow', async () => {