Skip to content
Merged
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
35 changes: 35 additions & 0 deletions src/commands/completions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string>();
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));
}
48 changes: 44 additions & 4 deletions src/commands/shell-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}") )
;;
Expand All @@ -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}") )
;;
Expand All @@ -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}") )
;;
*)
;;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -162,14 +183,33 @@ ${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]'
;;
status)
_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
;;
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
Expand All @@ -81,5 +87,6 @@ registerTicketsCommand(program);
registerInstallClaudeSkillCommand(program);
registerShellInitCommand(program);
registerWorktreeCommand(program);
registerCompletionsCommand(program);

program.parseAsync(process.argv).catch(handleError);
130 changes: 130 additions & 0 deletions tests/completions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
const mockGetBranches = configManager.getBranches as ReturnType<typeof vi.fn>;

function makeBranch(overrides: Partial<Branch>): 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<string> {
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('');
});
});
});
Loading