diff --git a/README.md b/README.md index 010248f..d5dd322 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ Shell integration is required for `gw clone`, `gw switch`, and `gw pr` to change the current shell directory. Without it, the Node CLI can only print the target path. +For `bash`, `zsh`, and `fish`, shell integration also registers tab completion +for `gw switch`, `gw remove`, and `gw rm`. Completion only suggests existing +worktree branch names such as `feature/login`; it does not suggest local-only +branches, remote branches, or `flat-tilde` folder names such as `feature~login`. + For a current-session setup: ```bash @@ -70,8 +75,9 @@ gw setup --install --shell nu Persistent setup writes generated integration to the user's config directory. For `bash`, `zsh`, and `nu`, it adds a small managed source block to the shell rc file. For `fish`, it installs an autoloaded `gw` function file under -`$XDG_CONFIG_HOME/fish/functions`. If shell integration is already active in the -current session, `gw setup --install` also sources the generated integration +`$XDG_CONFIG_HOME/fish/functions` and completion definitions under +`$XDG_CONFIG_HOME/fish/completions`. If shell integration is already active in +the current session, `gw setup --install` also sources the generated integration immediately. If shell integration is not active yet, `gw setup --install` prints the exact `source` command to run once in the current shell. New shell sessions load the persistent integration automatically. @@ -179,8 +185,8 @@ gw help - `version` is the config format version. - `primary` is the primary/default branch directory, usually `main` or `master`. - - `remote` is the Git remote used for remote branch lookups and deletes, - usually `origin`. + - `remote` is the Git remote used for cached remote-tracking refs, PR + checkout, and remote branch deletes, usually `origin`. - `path_style` controls worktree folder naming. The current supported value is `flat-tilde`. - `branch-prefix` is an optional branch prefix that `gw switch` can apply @@ -188,15 +194,20 @@ gw help - Worktree folders use the existing `flat-tilde` layout: branch slashes become `~`, so `feature/login` becomes `feature~login`. - `gw switch` treats explicit prefixed names as exact branch names. When a - branch prefix is configured and an unprefixed name is provided, it checks both - the prefixed and raw branch names locally and remotely, prompts if both exist, - and creates the prefixed variant if neither exists. + branch prefix is configured and an unprefixed name is provided, it checks the + prefixed and raw branch names against local branches, attached worktrees, and + cached remote-tracking refs. If both variants exist, it prompts; if neither + exists, it creates the prefixed variant. +- `gw switch` does not fetch or probe remotes. Run `git fetch` yourself before + switching to branches that were created remotely after your last fetch. - `gw pr ` requires `gh`, reads the PR head branch and owner, creates or reuses a fork remote, checks out local branch `pr_`, and switches to the `pr_` worktree. - `gw remove` refuses to remove the primary branch and refuses to remove the current worktree while the shell is inside it. Use `--worktree` or `-w` to remove only the worktree while keeping the local branch. +- Tab completion for `gw switch`, `gw remove`, and `gw rm` is intentionally + limited to existing worktrees, so creating a new worktree remains deliberate. - Relative `core.hooksPath` directories are copied from the primary worktree to newly created worktrees when possible. - The npm package ships a Node CLI plus shell wrappers for `bash`, `zsh`, diff --git a/src/cli.ts b/src/cli.ts index 0b28554..af5e960 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; 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'; @@ -26,6 +27,7 @@ registerPrCommand(program); registerRemoveCommand(program); registerCloneCommand(program); registerInitCommand(program); +registerCompletionCommand(program); registerShellInitCommand(program); registerSetupCommand(program); registerHelpCommand(program); diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..2f0c70c --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,79 @@ +import type { Command } from 'commander'; + +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'; + +function normalizeVariadicWords( + words: string[] | string | undefined +): string[] { + if (Array.isArray(words)) { + return words; + } + + if (typeof words === 'string') { + return [words]; + } + + return []; +} + +function sanitizeCompletionField(value: string): string { + return value.replace(/[\t\r\n]/gu, ' '); +} + +function formatCompletionCandidate(candidate: CompletionCandidate): string { + const value = sanitizeCompletionField(candidate.value); + const description = candidate.description + ? sanitizeCompletionField(candidate.description) + : ''; + + return description ? `${value}\t${description}` : value; +} + +export function registerCompletionCommand(program: Command): void { + program + .command('__complete', { hidden: true }) + .allowUnknownOption() + .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. + } + }); +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f658c5a..18426ab 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -114,14 +114,20 @@ export function registerSetupCommand(program: Command): void { process.stdout.write(` init file: ${installResult.initFilePath}\n`); } - if (installResult.updatedRcFile) { + if (installResult.completionFilePath) { process.stdout.write( - ` status: ${installResult.rcFileLabel} updated\n` + ` completion file: ${installResult.completionFilePath}\n` ); + } + + const statusLabel = installResult.completionFilePath + ? 'integration files' + : installResult.rcFileLabel; + + if (installResult.updatedRcFile) { + process.stdout.write(` status: ${statusLabel} updated\n`); } else { - process.stdout.write( - ` status: ${installResult.rcFileLabel} already up to date\n` - ); + process.stdout.write(` status: ${statusLabel} already up to date\n`); } if (!didRequestShellSource) { diff --git a/src/commands/switch.ts b/src/commands/switch.ts index f311c93..7396a29 100644 --- a/src/commands/switch.ts +++ b/src/commands/switch.ts @@ -10,12 +10,13 @@ import { requestDirectoryChange, resolveSwitchBranchWithPrompt, } from '@/commands/shared'; -import { getSwitchTargetFolderName } from '@/core/branches'; +import { + getSwitchBranchCandidates, + getSwitchTargetFolderName, +} from '@/core/branches'; import { getBranchPrefix, getRemoteName } from '@/core/config'; import { addWorktree, - branchExists, - fetchRemoteBranchRef, setBranchUpstream, syncRelativeHooksPath, } from '@/core/git'; @@ -75,6 +76,23 @@ export function registerSwitchCommand(program: Command): void { const remoteName = getRemoteName(context.config); const branchPrefix = getBranchPrefix(context.config); + const branchCandidates = getSwitchBranchCandidates( + rawBranch, + branchPrefix, + ignorePrefix + ); + + if (branchCandidates.length === 1) { + const candidateWorktree = findWorktreeForBranch( + worktrees, + branchCandidates[0] + ); + if (candidateWorktree) { + await requestDirectoryChange(candidateWorktree); + return; + } + } + const branchChoice = await resolveSwitchBranchWithPrompt( context, rawBranch, @@ -110,17 +128,12 @@ export function registerSwitchCommand(program: Command): void { throw new Error(`target path already exists: ${targetPath}`); } - if (await branchExists(context.anchorRepo, resolvedBranch)) { + if (branchChoice.local) { await addWorktree(context.anchorRepo, targetPath, { branchName: resolvedBranch, }); } else if (branchChoice.remote) { const remoteStartRef = `${remoteName}/${resolvedBranch}`; - await fetchRemoteBranchRef( - context.anchorRepo, - remoteName, - resolvedBranch - ); await addWorktree(context.anchorRepo, targetPath, { branchName: resolvedBranch, createBranch: true, diff --git a/src/core/branches.ts b/src/core/branches.ts index d0c611c..9ca7ceb 100644 --- a/src/core/branches.ts +++ b/src/core/branches.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import { branchExists, listLocalBranches, - remoteBranchExists, + remoteBranchRefExists, } from '@/core/git'; import { findWorktreeForBranch, @@ -158,7 +158,7 @@ async function buildAmbiguousCandidates( }); } -function getSwitchBranchCandidates( +export function getSwitchBranchCandidates( rawBranch: string, branchPrefix: string, ignorePrefix: boolean @@ -179,16 +179,17 @@ async function buildSwitchBranchCandidate( branchName: string, worktrees: WorktreeEntry[] ): Promise { - const local = await branchExists(options.repoPath, branchName); + const worktreePath = + findWorktreeForBranch(worktrees, branchName) || undefined; + const local = + worktreePath != null || (await branchExists(options.repoPath, branchName)); const remote = local ? false - : await remoteBranchExists( + : await remoteBranchRefExists( options.repoPath, options.remoteName, branchName ); - const worktreePath = - findWorktreeForBranch(worktrees, branchName) || undefined; return { branchName, diff --git a/src/core/completion.ts b/src/core/completion.ts new file mode 100644 index 0000000..fe0ef59 --- /dev/null +++ b/src/core/completion.ts @@ -0,0 +1,240 @@ +import { type WorktreeEntry } from '@/core/worktrees'; + +export interface CompletionCandidate { + value: string; + description?: string; +} + +export interface CompletionRequest { + completedWords: string[]; + current: string; +} + +export interface CompletionData { + branchPrefix?: string; + primaryBranch?: string; + worktrees?: WorktreeEntry[]; +} + +const ROOT_COMMANDS: CompletionCandidate[] = [ + { value: 'list', description: 'List worktrees' }, + { value: 'switch', description: 'Switch to an existing worktree' }, + { value: 'pr', description: 'Check out a pull request' }, + { value: 'remove', description: 'Remove a worktree' }, + { value: 'rm', description: 'Alias for remove' }, + { value: 'clone', description: 'Clone into a gw project' }, + { value: 'init', description: 'Initialize a gw project' }, + { value: 'shell-init', description: 'Print shell integration' }, + { value: 'setup', description: 'Configure shell integration' }, + { value: 'help', description: 'Show help' }, +]; + +const ROOT_OPTIONS: CompletionCandidate[] = [ + { value: '--version', description: 'Print version' }, + { value: '-v', description: 'Print version' }, + { value: '--help', description: 'Show help' }, + { value: '-h', description: 'Show help' }, +]; + +const SWITCH_OPTIONS: CompletionCandidate[] = [ + { + value: '--ignore-prefix', + description: 'Ignore branch prefix resolution', + }, +]; + +const REMOVE_OPTIONS: CompletionCandidate[] = [ + { value: '--force', description: 'Force removal' }, + { value: '--remote', description: 'Delete the remote branch' }, + { value: '--worktree', description: 'Only remove the worktree' }, + { value: '-w', description: 'Only remove the worktree' }, + { + value: '--ignore-prefix', + description: 'Ignore branch prefix resolution', + }, +]; + +const OPTION_ALIASES = new Map([['-w', '--worktree']]); +const WORKTREE_COMPLETION_COMMANDS = new Set(['switch', 'remove', 'rm']); + +function isGwCommandWord(word: string): boolean { + return word === 'gw' || word.endsWith('/gw'); +} + +function filterByCurrent( + candidates: CompletionCandidate[], + current: string +): CompletionCandidate[] { + return candidates.filter((candidate) => candidate.value.startsWith(current)); +} + +function hasOption(words: string[], option: string): boolean { + const canonicalOption = OPTION_ALIASES.get(option) || option; + return words.some( + (word) => (OPTION_ALIASES.get(word) || word) === canonicalOption + ); +} + +function filterUsedOptions( + candidates: CompletionCandidate[], + completedWords: string[] +): CompletionCandidate[] { + return candidates.filter( + (candidate) => !hasOption(completedWords, candidate.value) + ); +} + +function getBranchArguments(words: string[]): string[] { + const args: string[] = []; + let endOfOptions = false; + + for (const word of words) { + if (!endOfOptions && word === '--') { + endOfOptions = true; + continue; + } + + if (!endOfOptions && word.startsWith('-')) { + continue; + } + + args.push(word); + } + + return args; +} + +function getCommandOptions(command: string): CompletionCandidate[] { + switch (command) { + case 'switch': + return SWITCH_OPTIONS; + case 'remove': + case 'rm': + return REMOVE_OPTIONS; + default: + return []; + } +} + +function getDisplayBranchName( + branchName: string, + branchPrefix: string, + ignorePrefix: boolean +): string { + if ( + ignorePrefix || + branchPrefix === '' || + !branchName.startsWith(branchPrefix) + ) { + return branchName; + } + + const stripped = branchName.slice(branchPrefix.length); + return stripped || branchName; +} + +function getWorktreeBranchCandidates(options: { + command: string; + worktrees: WorktreeEntry[]; + primaryBranch: string; + branchPrefix: string; + ignorePrefix: boolean; +}): CompletionCandidate[] { + const worktrees = + options.command === 'switch' + ? options.worktrees + : options.worktrees.filter( + (worktree) => worktree.branchName !== options.primaryBranch + ); + const proposedValues = worktrees.map((worktree) => + getDisplayBranchName( + worktree.branchName, + options.branchPrefix, + options.ignorePrefix + ) + ); + const proposedCounts = new Map(); + + for (const value of proposedValues) { + proposedCounts.set(value, (proposedCounts.get(value) || 0) + 1); + } + + return worktrees.map((worktree, index) => { + const proposedValue = proposedValues[index]; + const value = + proposedCounts.get(proposedValue) === 1 + ? proposedValue + : worktree.branchName; + + return { + value, + description: 'existing worktree', + }; + }); +} + +export function normalizeCompletionWords(rawWords: string[]): string[] { + if (rawWords.length === 0) { + return []; + } + + return isGwCommandWord(rawWords[0]) ? rawWords.slice(1) : rawWords; +} + +export function needsWorktreeCompletion(request: CompletionRequest): boolean { + const [command, ...commandWords] = request.completedWords; + if (!command || !WORKTREE_COMPLETION_COMMANDS.has(command)) { + return false; + } + + if (request.current.startsWith('-')) { + return false; + } + + return getBranchArguments(commandWords).length === 0; +} + +export function buildCompletionCandidates( + request: CompletionRequest, + data: CompletionData = {} +): CompletionCandidate[] { + const [command, ...commandWords] = request.completedWords; + + if (!command) { + return filterByCurrent( + request.current.startsWith('-') ? ROOT_OPTIONS : ROOT_COMMANDS, + request.current + ); + } + + if (!WORKTREE_COMPLETION_COMMANDS.has(command)) { + return []; + } + + const commandOptions = getCommandOptions(command); + if (request.current.startsWith('-')) { + return filterByCurrent( + filterUsedOptions(commandOptions, commandWords), + request.current + ); + } + + if (getBranchArguments(commandWords).length > 0) { + return []; + } + + if (!data.worktrees || !data.primaryBranch) { + return []; + } + + return filterByCurrent( + getWorktreeBranchCandidates({ + command, + worktrees: data.worktrees, + primaryBranch: data.primaryBranch, + branchPrefix: data.branchPrefix || '', + ignorePrefix: hasOption(commandWords, '--ignore-prefix'), + }), + request.current + ); +} diff --git a/src/core/git.ts b/src/core/git.ts index 31229b3..57a09e2 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -80,28 +80,6 @@ export async function remoteBranchRefExists( return result.exitCode === 0; } -export async function remoteBranchExists( - repoPath: string, - remoteName: string, - branchName: string -): Promise { - if (await remoteBranchRefExists(repoPath, remoteName, branchName)) { - return true; - } - - const result = await runGit( - [ - 'ls-remote', - '--exit-code', - '--heads', - remoteName, - `refs/heads/${branchName}`, - ], - repoPath - ); - return result.exitCode === 0; -} - export async function fetchRemoteBranchRef( repoPath: string, remoteName: string, diff --git a/src/core/shell.ts b/src/core/shell.ts index 2d914b4..4decfee 100644 --- a/src/core/shell.ts +++ b/src/core/shell.ts @@ -12,13 +12,12 @@ const GW_RC_BLOCK_START = '# >>> gw >>>'; const GW_RC_BLOCK_END = '# <<< gw <<<'; const BASH_RC_CANDIDATES = ['.bashrc', '.bash_profile', '.profile'] as const; -const BASH_LIKE_SHELLS = new Set(['bash', 'zsh']); - export interface ShellIntegrationInstallResult { shell: ShellName; rcFilePath: string; rcFileLabel: string; initFilePath: string; + completionFilePath?: string; updatedRcFile: boolean; } @@ -60,6 +59,10 @@ function getFishFunctionFilePath(): string { return join(getConfigRoot(), 'fish', 'functions', 'gw.fish'); } +function getFishCompletionFilePath(): string { + return join(getConfigRoot(), 'fish', 'completions', 'gw.fish'); +} + async function detectParentShellName(): Promise { try { const result = await execa('ps', [ @@ -218,21 +221,31 @@ export async function installShellIntegration( const rcFilePath = await getShellRcFilePath(shell); if (shell === 'fish') { + const completionFilePath = getFishCompletionFilePath(); const initText = renderShellInit(shell); + const completionText = renderFishCompletionInit(); const existingInitText = await readTextFile(initFilePath); - const updated = existingInitText !== initText; + const existingCompletionText = await readTextFile(completionFilePath); + const updatedInit = existingInitText !== initText; + const updatedCompletion = existingCompletionText !== completionText; - if (updated) { + if (updatedInit) { await mkdir(dirname(initFilePath), { recursive: true }); await writeFile(initFilePath, initText, 'utf8'); } + if (updatedCompletion) { + await mkdir(dirname(completionFilePath), { recursive: true }); + await writeFile(completionFilePath, completionText, 'utf8'); + } + return { shell, rcFilePath: initFilePath, rcFileLabel: 'function file', initFilePath, - updatedRcFile: updated, + completionFilePath, + updatedRcFile: updatedInit || updatedCompletion, }; } @@ -272,7 +285,7 @@ export function getSessionActivationCommand(shell: ShellName): string { } } -function renderBashLikeShellInit(): string { +function renderBashLikeShellWrapper(): string { return `gw() { if [[ $# -eq 0 ]]; then command gw @@ -311,6 +324,82 @@ function renderBashLikeShellInit(): string { `; } +function renderBashCompletionInit(): string { + return `__gw_bash_complete() { + local _gw_current _gw_value _gw_description + local -a _gw_words_before_current + + COMPREPLY=() + _gw_current="\${COMP_WORDS[COMP_CWORD]}" + _gw_words_before_current=("\${COMP_WORDS[@]:0:COMP_CWORD}") + + while IFS=$'\t' read -r _gw_value _gw_description; do + if [[ -n "$_gw_value" ]]; then + COMPREPLY+=("$_gw_value") + fi + done < <(GW_COMPLETE_CURRENT="$_gw_current" command gw __complete -- "\${_gw_words_before_current[@]}") +} + +complete -F __gw_bash_complete gw +`; +} + +function renderZshCompletionInit(): string { + return `__gw_zsh_complete() { + local _gw_current _gw_value _gw_description + 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 + + while IFS=$'\t' read -r _gw_value _gw_description; do + if [[ -n "$_gw_value" ]]; 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[@]}") + + if (( \${#_gw_values[@]} > 0 )); then + compadd -d _gw_descriptions -- "\${_gw_values[@]}" + fi +} + +autoload -Uz compinit +if ! (( $+functions[compdef] )); then + compinit -u +fi +if (( $+functions[compdef] )); then + compdef __gw_zsh_complete gw +fi +`; +} + +function renderBashShellInit(): string { + return `${renderBashLikeShellWrapper()}${renderBashCompletionInit()}`; +} + +function renderZshShellInit(): string { + return `${renderBashLikeShellWrapper()}${renderZshCompletionInit()}`; +} + +function renderFishCompletionInit(): string { + return `function __gw_fish_complete + set -l current (commandline -ct) + set -l tokens (commandline -opc) + + 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 +end + +complete -c gw -f -a '(__gw_fish_complete)' +`; +} + function renderFishShellInit(): string { return `function gw if test (count $argv) -eq 0 @@ -354,6 +443,7 @@ function renderFishShellInit(): string { command rm -f -- "$cwd_file" "$source_file" return $_gw_status end +${renderFishCompletionInit()} `; } @@ -395,13 +485,14 @@ function renderNuShellInit(): string { } export function renderShellInit(shell: ShellName): string { - if (BASH_LIKE_SHELLS.has(shell)) { - return renderBashLikeShellInit(); - } - - if (shell === 'fish') { - return renderFishShellInit(); + switch (shell) { + case 'bash': + return renderBashShellInit(); + case 'zsh': + return renderZshShellInit(); + case 'fish': + return renderFishShellInit(); + case 'nu': + return renderNuShellInit(); } - - return renderNuShellInit(); } diff --git a/test/cli/commands.integration.test.ts b/test/cli/commands.integration.test.ts index 870bbc6..b073b74 100644 --- a/test/cli/commands.integration.test.ts +++ b/test/cli/commands.integration.test.ts @@ -89,6 +89,13 @@ async function createPathWithoutGh(): Promise { return binDir; } +function parseCompletionValues(stdout: unknown): string[] { + return String(stdout ?? '') + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => line.split('\t')[0]); +} + describe('CLI integration', () => { it('prints the package version', async () => { const workDir = await makeTempDir('gw-version-'); @@ -176,6 +183,67 @@ describe('CLI integration', () => { await expect(branchExists(mainPath, 'feature/test')).resolves.toBe(true); }, 60_000); + it('completes only existing worktree branch names', async () => { + const fixture = await createRemoteFixture( + ['feature/test', 'feature/uncreated'], + 'main' + ); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const mainPath = join(workDir, 'demo', 'main'); + const beforeSwitch = await runCli(['__complete', '--', 'gw', 'switch'], { + cwd: mainPath, + env: { + ...process.env, + GW_COMPLETE_CURRENT: 'feature/', + }, + }); + expect(beforeSwitch.exitCode).toBe(0); + expect(parseCompletionValues(beforeSwitch.stdout)).toEqual([]); + + await runCliWithCwdCapture(['switch', 'feature/test'], { cwd: mainPath }); + + const switchResult = await runCli(['__complete', '--', 'gw', 'switch'], { + cwd: mainPath, + env: { + ...process.env, + GW_COMPLETE_CURRENT: 'feature/', + }, + }); + expect(switchResult.exitCode).toBe(0); + expect(parseCompletionValues(switchResult.stdout)).toEqual([ + 'feature/test', + ]); + expect(switchResult.stdout).not.toContain('feature~test'); + expect(switchResult.stdout).not.toContain('feature/uncreated'); + + const removeResult = await runCli(['__complete', '--', 'gw', 'remove'], { + cwd: mainPath, + env: { + ...process.env, + GW_COMPLETE_CURRENT: '', + }, + }); + expect(removeResult.exitCode).toBe(0); + expect(parseCompletionValues(removeResult.stdout)).toEqual([ + 'feature/test', + ]); + + const rmResult = await runCli(['__complete', '--', 'gw', 'rm'], { + cwd: mainPath, + env: { + ...process.env, + GW_COMPLETE_CURRENT: 'feature/', + }, + }); + expect(rmResult.exitCode).toBe(0); + expect(parseCompletionValues(rmResult.stdout)).toEqual(['feature/test']); + }, 60_000); + it('rejects --worktree when no matching worktree exists', async () => { const fixture = await createRemoteFixture(['feature/test'], 'main'); const workDir = await createWorkDir(fixture.rootDir); @@ -216,7 +284,7 @@ describe('CLI integration', () => { expect(result.stderr).toContain('--remote cannot be used with --worktree'); }, 60_000); - it('switches to prefixed remote branches by unprefixed name', async () => { + it('switches to cached prefixed remote branches by unprefixed name', async () => { const branchName = 'amadeus/diffs-improved-line-selection'; const fixture = await createRemoteFixture([branchName], 'main'); const workDir = await createWorkDir(fixture.rootDir); @@ -249,6 +317,43 @@ describe('CLI integration', () => { expect(upstream.stdout).toBe(`origin/${branchName}`); }, 60_000); + it('does not discover remote branches created after clone', async () => { + const branchName = 'feature/unfetched'; + const fixture = await createRemoteFixture([], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + await runGit(['checkout', '-b', branchName], { cwd: fixture.seedPath }); + await commitEmpty(fixture.seedPath, branchName); + await runGit(['push', '-u', 'origin', branchName], { + cwd: fixture.seedPath, + }); + + const mainPath = join(workDir, 'demo', 'main'); + const targetPath = join(workDir, 'demo', 'feature~unfetched'); + const switchResult = await runCliWithCwdCapture(['switch', branchName], { + cwd: mainPath, + }); + + expect(switchResult.result.exitCode).toBe(0); + expect(switchResult.targetPath).toBe(await canonicalPath(targetPath)); + expect(await pathExists(targetPath)).toBe(true); + + const currentBranch = await runGit(['branch', '--show-current'], { + cwd: targetPath, + }); + expect(currentBranch.stdout).toBe(branchName); + + const upstream = await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: targetPath, reject: false } + ); + expect(upstream.exitCode).not.toBe(0); + }, 60_000); + it('uses a prefixed folder name when the stripped folder has another branch', async () => { const prefixedBranch = 'amadeus/topic'; const fixture = await createRemoteFixture([prefixedBranch], 'main'); diff --git a/test/cli/shell.integration.test.ts b/test/cli/shell.integration.test.ts index 6e72d17..0fca7a6 100644 --- a/test/cli/shell.integration.test.ts +++ b/test/cli/shell.integration.test.ts @@ -148,17 +148,24 @@ describe('shell wrapper integration', () => { 'functions', 'gw.fish' ); + const completionFile = join( + fixture.configDir, + 'fish', + 'completions', + 'gw.fish' + ); const result = await execa( 'fish', [ '-c', - 'gw setup --install --shell fish; test -f "$XDG_CONFIG_HOME/fish/functions/gw.fish"; or exit 1; test ! -e "$XDG_CONFIG_HOME/fish/config.fish"; or exit 1; source "$XDG_CONFIG_HOME/fish/functions/gw.fish"; gw --help >/dev/null; functions -q gw; or exit 1; printf OK', + 'gw setup --install --shell fish; test -f "$XDG_CONFIG_HOME/fish/functions/gw.fish"; or exit 1; test -f "$XDG_CONFIG_HOME/fish/completions/gw.fish"; or exit 1; test ! -e "$XDG_CONFIG_HOME/fish/config.fish"; or exit 1; source "$XDG_CONFIG_HOME/fish/functions/gw.fish"; gw --help >/dev/null; functions -q gw; or exit 1; printf OK', ], { env: fixture.env } ); expect(result.stdout).toContain(`function file: ${functionFile}`); + expect(result.stdout).toContain(`completion file: ${completionFile}`); expect(result.stdout).toContain( 'Restart shell or run the following command:\n\n' ); @@ -178,7 +185,7 @@ describe('shell wrapper integration', () => { [ '--no-config', '-c', - 'gw shell-init --shell fish | source; functions -e __gw_needs_cd; function __gw_needs_cd; return 1; end; gw setup --install --shell fish >/dev/null; test -f "$XDG_CONFIG_HOME/fish/functions/gw.fish"; or exit 1; test ! -e "$XDG_CONFIG_HOME/fish/config.fish"; or exit 1; cd $argv[1]; gw clone demo $argv[2] >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/main"; or exit 1; printf OK', + 'gw shell-init --shell fish | source; functions -e __gw_needs_cd; function __gw_needs_cd; return 1; end; gw setup --install --shell fish >/dev/null; test -f "$XDG_CONFIG_HOME/fish/functions/gw.fish"; or exit 1; test -f "$XDG_CONFIG_HOME/fish/completions/gw.fish"; or exit 1; test ! -e "$XDG_CONFIG_HOME/fish/config.fish"; or exit 1; cd $argv[1]; gw clone demo $argv[2] >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/main"; or exit 1; printf OK', fixture.workDir, fixture.originPath, ], diff --git a/test/core/branches.test.ts b/test/core/branches.test.ts index a707229..5cb6de3 100644 --- a/test/core/branches.test.ts +++ b/test/core/branches.test.ts @@ -1,4 +1,4 @@ -import { createRemoteFixture, runGit } from '@test/helpers'; +import { commitEmpty, createRemoteFixture, runGit } from '@test/helpers'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -118,6 +118,32 @@ describe('resolveSwitchBranchName', () => { expect(result.candidate.remote).toBe(true); }); + it('does not discover unfetched remote branches', async () => { + const fixture = await createRemoteFixture([], 'main'); + const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); + + await runGit(['checkout', '-b', 'users/alice'], { cwd: fixture.seedPath }); + await commitEmpty(fixture.seedPath, 'users/alice'); + await runGit(['push', '-u', 'origin', 'users/alice'], { + cwd: fixture.seedPath, + }); + + const result = await resolveSwitchBranchName({ + repoPath: clonePath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('users/alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(false); + }); + it('uses the unprefixed branch when it is the only matching existing branch', async () => { const fixture = await createRemoteFixture(['alice'], 'main'); const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); diff --git a/test/core/completion.test.ts b/test/core/completion.test.ts new file mode 100644 index 0000000..0de7bd2 --- /dev/null +++ b/test/core/completion.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCompletionCandidates, + needsWorktreeCompletion, + normalizeCompletionWords, +} from '@/core/completion'; +import { type WorktreeEntry } from '@/core/worktrees'; + +const worktrees: WorktreeEntry[] = [ + { branchName: 'main', path: '/repo/main' }, + { branchName: 'feature/test', path: '/repo/feature~test' }, +]; + +function values(candidates: { value: string }[]): string[] { + return candidates.map((candidate) => candidate.value); +} + +describe('completion helpers', () => { + it('normalizes a leading gw command word', () => { + expect(normalizeCompletionWords(['gw', 'switch'])).toEqual(['switch']); + expect(normalizeCompletionWords(['/usr/local/bin/gw', 'switch'])).toEqual([ + 'switch', + ]); + }); + + it('completes root commands', () => { + expect( + values(buildCompletionCandidates({ completedWords: [], current: 'sw' })) + ).toEqual(['switch']); + }); + + it('suggests existing switch worktree branch names only', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['switch'], current: '' }, + { primaryBranch: 'main', worktrees } + ) + ) + ).toEqual(['main', 'feature/test']); + }); + + it('suggests removable worktree branches except the primary branch', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['remove'], current: '' }, + { primaryBranch: 'main', worktrees } + ) + ) + ).toEqual(['feature/test']); + }); + + it('uses the remove candidate behavior for the rm alias', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['rm'], current: 'feature/' }, + { primaryBranch: 'main', worktrees } + ) + ) + ).toEqual(['feature/test']); + }); + + it('does not suggest another branch after a branch argument exists', () => { + expect( + buildCompletionCandidates( + { completedWords: ['switch', 'feature/test'], current: '' }, + { primaryBranch: 'main', worktrees } + ) + ).toEqual([]); + }); + + it('strips configured branch prefixes when unambiguous', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['switch'], current: '' }, + { + branchPrefix: 'amadeus/', + primaryBranch: 'main', + worktrees: [ + { branchName: 'main', path: '/repo/main' }, + { branchName: 'amadeus/fix-build', path: '/repo/fix-build' }, + ], + } + ) + ) + ).toEqual(['main', 'fix-build']); + }); + + it('keeps full branch names when prefix stripping would be ambiguous', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['switch'], current: '' }, + { + branchPrefix: 'amadeus/', + primaryBranch: 'main', + worktrees: [ + { branchName: 'amadeus/topic', path: '/repo/amadeus~topic' }, + { branchName: 'topic', path: '/repo/topic' }, + ], + } + ) + ) + ).toEqual(['amadeus/topic', 'topic']); + }); + + it('does not strip branch prefixes when --ignore-prefix is present', () => { + expect( + values( + buildCompletionCandidates( + { completedWords: ['switch', '--ignore-prefix'], current: '' }, + { + branchPrefix: 'amadeus/', + primaryBranch: 'main', + worktrees: [ + { branchName: 'amadeus/fix-build', path: '/repo/fix-build' }, + ], + } + ) + ) + ).toEqual(['amadeus/fix-build']); + }); + + it('requires worktree data only for branch argument completion', () => { + expect( + needsWorktreeCompletion({ completedWords: ['switch'], current: '' }) + ).toBe(true); + expect( + needsWorktreeCompletion({ completedWords: ['switch'], current: '--' }) + ).toBe(false); + }); +}); diff --git a/test/core/git.test.ts b/test/core/git.test.ts index ba25cc2..c70e0f1 100644 --- a/test/core/git.test.ts +++ b/test/core/git.test.ts @@ -12,7 +12,7 @@ import { detectRemoteHeadFromRepo, detectRemoteHeadFromUrl, getPreferredRemote, - remoteBranchExists, + remoteBranchRefExists, syncRelativeHooksPath, } from '@/core/git'; @@ -48,14 +48,16 @@ describe('git helpers', () => { ); }); - it('checks remote branch refs exactly', async () => { + it('checks cached remote branch refs exactly', async () => { const fixture = await createRemoteFixture(['users/alice'], 'main'); + const clonePath = join(fixture.rootDir, 'clone'); + await runGit(['clone', fixture.originPath, clonePath]); await expect( - remoteBranchExists(fixture.seedPath, 'origin', 'users/alice') + remoteBranchRefExists(clonePath, 'origin', 'users/alice') ).resolves.toBe(true); await expect( - remoteBranchExists(fixture.seedPath, 'origin', 'alice') + remoteBranchRefExists(clonePath, 'origin', 'alice') ).resolves.toBe(false); }); diff --git a/test/core/shell.test.ts b/test/core/shell.test.ts index c306770..0132497 100644 --- a/test/core/shell.test.ts +++ b/test/core/shell.test.ts @@ -17,4 +17,10 @@ describe('shell integration helpers', () => { expect(renderShellInit('nu')).toContain('GW_CWD_FILE'); expect(renderShellInit('nu')).toContain('GW_SOURCE_FILE'); }); + + it('renders shell completion hooks for supported shells', () => { + expect(renderShellInit('bash')).toContain('__gw_bash_complete'); + expect(renderShellInit('zsh')).toContain('__gw_zsh_complete'); + expect(renderShellInit('fish')).toContain('complete -c gw'); + }); });