diff --git a/src/commands/completions.ts b/src/commands/completions.ts new file mode 100644 index 0000000..be1c17a --- /dev/null +++ b/src/commands/completions.ts @@ -0,0 +1,35 @@ +import type { Command } from 'commander'; +import { requireTrackedRepo } from '../utils/detect'; +import { configManager } from '../config/manager'; + +async function runCompletions(type: string | undefined): Promise { + try { + const projectId = await requireTrackedRepo(); + const { branches } = await configManager.getBranches(projectId); + + if (type === 'branches') { + for (const b of branches) { + if (b.status === 'active' || b.status === 'pr_open') { + console.log(b.branchName); + } + } + } else if (type === 'tickets') { + const seen = new Set(); + for (const b of branches) { + if (b.ticketId && !seen.has(b.ticketId)) { + seen.add(b.ticketId); + console.log(b.ticketId); + } + } + } + } catch { + // Silent — no output on error (repo not tracked, file missing, etc.) + } +} + +export function registerCompletionsCommand(program: Command): void { + program + .command('_completions [type]') + .description('Output completion candidates (internal)') + .action((type: string | undefined) => runCompletions(type)); +} diff --git a/src/commands/shell-init.ts b/src/commands/shell-init.ts index b10518c..665de73 100644 --- a/src/commands/shell-init.ts +++ b/src/commands/shell-init.ts @@ -72,6 +72,11 @@ _morg_completion() { pr) COMPREPLY=( $(compgen -W "create review view" -- "\${cur}") ) ;; + view) + if [[ "\${COMP_WORDS[1]}" == "pr" ]]; then + COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") ) + fi + ;; worktree) COMPREPLY=( $(compgen -W "list clean" -- "\${cur}") ) ;; @@ -84,6 +89,16 @@ _morg_completion() { completion) COMPREPLY=( $(compgen -W "bash zsh" -- "\${cur}") ) ;; + switch|complete|delete|untrack) + COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") ) + ;; + track) + if [[ \${COMP_CWORD} == 2 ]]; then + COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") ) + elif [[ \${COMP_CWORD} == 3 ]]; then + COMPREPLY=( $(compgen -W "$(command morg _completions tickets 2>/dev/null)" -- "\${cur}") ) + fi + ;; start) COMPREPLY=( $(compgen -W "--worktree --base" -- "\${cur}") ) ;; @@ -94,7 +109,7 @@ _morg_completion() { COMPREPLY=( $(compgen -W "--json --short" -- "\${cur}") ) ;; tickets|ticket) - COMPREPLY=( $(compgen -W "--plain --json" -- "\${cur}") ) + COMPREPLY=( $(compgen -W "--plain --json $(command morg _completions tickets 2>/dev/null)" -- "\${cur}") ) ;; *) ;; @@ -128,9 +143,15 @@ ${commandDefs} args) case \$words[2] in pr) - local -a subcmds - subcmds=('create' 'review' 'view') - _describe 'subcommand' subcmds + if [[ \$CURRENT -eq 4 && \$words[3] == "view" ]]; then + local -a branches + branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"}) + compadd -a branches + else + local -a subcmds + subcmds=('create' 'review' 'view') + _describe 'subcommand' subcmds + fi ;; worktree) local -a subcmds @@ -162,6 +183,22 @@ ${commandDefs} shells=('bash' 'zsh') _describe 'shell' shells ;; + switch|complete|delete|untrack) + local -a branches + branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"}) + compadd -a branches + ;; + track) + if [[ \$CURRENT -eq 3 ]]; then + local -a branches + branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"}) + compadd -a branches + elif [[ \$CURRENT -eq 4 ]]; then + local -a tickets + tickets=(\${(f)"$(command morg _completions tickets 2>/dev/null)"}) + compadd -a tickets + fi + ;; start) _arguments '--worktree[Create git worktree]' '--base[Base branch]' ;; @@ -169,7 +206,10 @@ ${commandDefs} _arguments '--json[Output as JSON]' '--short[Short output]' ;; tickets|ticket) + local -a tickets + tickets=(\${(f)"$(command morg _completions tickets 2>/dev/null)"}) _arguments '--plain[Plain output]' '--json[Output as JSON]' + compadd -a tickets ;; esac ;; diff --git a/src/index.ts b/src/index.ts index 1904694..6fad6ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { registerTicketsCommand } from './commands/tickets'; import { registerInstallClaudeSkillCommand } from './commands/install-claude-skill'; import { registerShellInitCommand } from './commands/shell-init'; import { registerWorktreeCommand } from './commands/worktree'; +import { registerCompletionsCommand } from './commands/completions'; import { getCurrentBranch } from './git/index'; import { configManager } from './config/manager'; import { findBranchCaseInsensitive } from './utils/ticket'; @@ -56,7 +57,12 @@ program.action(async () => { } }); -const NO_CONFIG_COMMANDS = new Set(['config', 'install-claude-skill', 'shell-init']); +const NO_CONFIG_COMMANDS = new Set([ + 'config', + 'install-claude-skill', + 'shell-init', + '_completions', +]); program.hook('preAction', async (_thisCommand, actionCommand) => { if (!NO_CONFIG_COMMANDS.has(actionCommand.name())) await requireConfig(); }); @@ -81,5 +87,6 @@ registerTicketsCommand(program); registerInstallClaudeSkillCommand(program); registerShellInitCommand(program); registerWorktreeCommand(program); +registerCompletionsCommand(program); program.parseAsync(process.argv).catch(handleError); diff --git a/tests/completions.test.ts b/tests/completions.test.ts new file mode 100644 index 0000000..eb9d153 --- /dev/null +++ b/tests/completions.test.ts @@ -0,0 +1,130 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import type { Branch, BranchesFile } from '../src/config/schemas'; + +vi.mock('../src/utils/detect', () => ({ + requireTrackedRepo: vi.fn(), +})); + +vi.mock('../src/config/manager', () => ({ + configManager: { + getBranches: vi.fn(), + }, +})); + +import { requireTrackedRepo } from '../src/utils/detect'; +import { configManager } from '../src/config/manager'; + +const mockRequireTrackedRepo = requireTrackedRepo as ReturnType; +const mockGetBranches = configManager.getBranches as ReturnType; + +function makeBranch(overrides: Partial): Branch { + return { + id: 'test-id', + branchName: 'feature/test', + ticketId: null, + ticketTitle: null, + ticketUrl: null, + status: 'active', + prNumber: null, + prUrl: null, + prStatus: null, + worktreePath: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + ...overrides, + }; +} + +// Dynamically import the module to get the action handler +async function runCompletions(type?: string): Promise { + const { registerCompletionsCommand } = await import('../src/commands/completions'); + const { Command } = await import('commander'); + const program = new Command(); + registerCompletionsCommand(program); + + const output: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => output.push(String(args[0])); + + try { + await program.parseAsync(['node', 'test', '_completions', ...(type ? [type] : [])]); + } finally { + console.log = origLog; + } + + return output.join('\n'); +} + +describe('_completions command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequireTrackedRepo.mockResolvedValue('project-123'); + }); + + describe('branches type', () => { + it('outputs active and pr_open branch names', async () => { + const branchesFile: BranchesFile = { + version: 1, + branches: [ + makeBranch({ branchName: 'feature/one', status: 'active' }), + makeBranch({ branchName: 'feature/two', status: 'pr_open' }), + makeBranch({ branchName: 'feature/done', status: 'done' }), + makeBranch({ branchName: 'feature/abandoned', status: 'abandoned' }), + ], + }; + mockGetBranches.mockResolvedValue(branchesFile); + + const output = await runCompletions('branches'); + expect(output).toBe('feature/one\nfeature/two'); + }); + + it('outputs nothing when no branches exist', async () => { + mockGetBranches.mockResolvedValue({ version: 1, branches: [] }); + + const output = await runCompletions('branches'); + expect(output).toBe(''); + }); + }); + + describe('tickets type', () => { + it('outputs deduplicated ticket IDs', async () => { + const branchesFile: BranchesFile = { + version: 1, + branches: [ + makeBranch({ branchName: 'feature/one', ticketId: 'MORG-1' }), + makeBranch({ branchName: 'feature/two', ticketId: 'MORG-2' }), + makeBranch({ branchName: 'feature/three', ticketId: 'MORG-1' }), + makeBranch({ branchName: 'feature/no-ticket', ticketId: null }), + ], + }; + mockGetBranches.mockResolvedValue(branchesFile); + + const output = await runCompletions('tickets'); + expect(output).toBe('MORG-1\nMORG-2'); + }); + }); + + describe('error handling', () => { + it('outputs nothing when repo is not tracked', async () => { + mockRequireTrackedRepo.mockRejectedValue(new Error('Not tracked')); + + const output = await runCompletions('branches'); + expect(output).toBe(''); + }); + + it('outputs nothing when getBranches fails', async () => { + mockGetBranches.mockRejectedValue(new Error('File not found')); + + const output = await runCompletions('branches'); + expect(output).toBe(''); + }); + + it('outputs nothing for unknown type', async () => { + mockGetBranches.mockResolvedValue({ version: 1, branches: [] }); + + const output = await runCompletions('unknown'); + expect(output).toBe(''); + }); + }); +});