Skip to content

Commit 1b04266

Browse files
feat: add dynamic shell autocompletion for branches and tickets (#46)
Add a hidden `_completions` command that outputs tracked branch names or ticket IDs for shell tab-completion. Update both Bash and Zsh completion scripts to call it dynamically for commands that accept branch/ticket arguments (switch, complete, delete, untrack, track, pr view, ticket, tickets).
1 parent 1426832 commit 1b04266

4 files changed

Lines changed: 217 additions & 5 deletions

File tree

src/commands/completions.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Command } from 'commander';
2+
import { requireTrackedRepo } from '../utils/detect';
3+
import { configManager } from '../config/manager';
4+
5+
async function runCompletions(type: string | undefined): Promise<void> {
6+
try {
7+
const projectId = await requireTrackedRepo();
8+
const { branches } = await configManager.getBranches(projectId);
9+
10+
if (type === 'branches') {
11+
for (const b of branches) {
12+
if (b.status === 'active' || b.status === 'pr_open') {
13+
console.log(b.branchName);
14+
}
15+
}
16+
} else if (type === 'tickets') {
17+
const seen = new Set<string>();
18+
for (const b of branches) {
19+
if (b.ticketId && !seen.has(b.ticketId)) {
20+
seen.add(b.ticketId);
21+
console.log(b.ticketId);
22+
}
23+
}
24+
}
25+
} catch {
26+
// Silent — no output on error (repo not tracked, file missing, etc.)
27+
}
28+
}
29+
30+
export function registerCompletionsCommand(program: Command): void {
31+
program
32+
.command('_completions [type]')
33+
.description('Output completion candidates (internal)')
34+
.action((type: string | undefined) => runCompletions(type));
35+
}

src/commands/shell-init.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ _morg_completion() {
7272
pr)
7373
COMPREPLY=( $(compgen -W "create review view" -- "\${cur}") )
7474
;;
75+
view)
76+
if [[ "\${COMP_WORDS[1]}" == "pr" ]]; then
77+
COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") )
78+
fi
79+
;;
7580
worktree)
7681
COMPREPLY=( $(compgen -W "list clean" -- "\${cur}") )
7782
;;
@@ -84,6 +89,16 @@ _morg_completion() {
8489
completion)
8590
COMPREPLY=( $(compgen -W "bash zsh" -- "\${cur}") )
8691
;;
92+
switch|complete|delete|untrack)
93+
COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") )
94+
;;
95+
track)
96+
if [[ \${COMP_CWORD} == 2 ]]; then
97+
COMPREPLY=( $(compgen -W "$(command morg _completions branches 2>/dev/null)" -- "\${cur}") )
98+
elif [[ \${COMP_CWORD} == 3 ]]; then
99+
COMPREPLY=( $(compgen -W "$(command morg _completions tickets 2>/dev/null)" -- "\${cur}") )
100+
fi
101+
;;
87102
start)
88103
COMPREPLY=( $(compgen -W "--worktree --base" -- "\${cur}") )
89104
;;
@@ -94,7 +109,7 @@ _morg_completion() {
94109
COMPREPLY=( $(compgen -W "--json --short" -- "\${cur}") )
95110
;;
96111
tickets|ticket)
97-
COMPREPLY=( $(compgen -W "--plain --json" -- "\${cur}") )
112+
COMPREPLY=( $(compgen -W "--plain --json $(command morg _completions tickets 2>/dev/null)" -- "\${cur}") )
98113
;;
99114
*)
100115
;;
@@ -128,9 +143,15 @@ ${commandDefs}
128143
args)
129144
case \$words[2] in
130145
pr)
131-
local -a subcmds
132-
subcmds=('create' 'review' 'view')
133-
_describe 'subcommand' subcmds
146+
if [[ \$CURRENT -eq 4 && \$words[3] == "view" ]]; then
147+
local -a branches
148+
branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"})
149+
compadd -a branches
150+
else
151+
local -a subcmds
152+
subcmds=('create' 'review' 'view')
153+
_describe 'subcommand' subcmds
154+
fi
134155
;;
135156
worktree)
136157
local -a subcmds
@@ -162,14 +183,33 @@ ${commandDefs}
162183
shells=('bash' 'zsh')
163184
_describe 'shell' shells
164185
;;
186+
switch|complete|delete|untrack)
187+
local -a branches
188+
branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"})
189+
compadd -a branches
190+
;;
191+
track)
192+
if [[ \$CURRENT -eq 3 ]]; then
193+
local -a branches
194+
branches=(\${(f)"$(command morg _completions branches 2>/dev/null)"})
195+
compadd -a branches
196+
elif [[ \$CURRENT -eq 4 ]]; then
197+
local -a tickets
198+
tickets=(\${(f)"$(command morg _completions tickets 2>/dev/null)"})
199+
compadd -a tickets
200+
fi
201+
;;
165202
start)
166203
_arguments '--worktree[Create git worktree]' '--base[Base branch]'
167204
;;
168205
status)
169206
_arguments '--json[Output as JSON]' '--short[Short output]'
170207
;;
171208
tickets|ticket)
209+
local -a tickets
210+
tickets=(\${(f)"$(command morg _completions tickets 2>/dev/null)"})
172211
_arguments '--plain[Plain output]' '--json[Output as JSON]'
212+
compadd -a tickets
173213
;;
174214
esac
175215
;;

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { registerTicketsCommand } from './commands/tickets';
2121
import { registerInstallClaudeSkillCommand } from './commands/install-claude-skill';
2222
import { registerShellInitCommand } from './commands/shell-init';
2323
import { registerWorktreeCommand } from './commands/worktree';
24+
import { registerCompletionsCommand } from './commands/completions';
2425
import { getCurrentBranch } from './git/index';
2526
import { configManager } from './config/manager';
2627
import { findBranchCaseInsensitive } from './utils/ticket';
@@ -56,7 +57,12 @@ program.action(async () => {
5657
}
5758
});
5859

59-
const NO_CONFIG_COMMANDS = new Set(['config', 'install-claude-skill', 'shell-init']);
60+
const NO_CONFIG_COMMANDS = new Set([
61+
'config',
62+
'install-claude-skill',
63+
'shell-init',
64+
'_completions',
65+
]);
6066
program.hook('preAction', async (_thisCommand, actionCommand) => {
6167
if (!NO_CONFIG_COMMANDS.has(actionCommand.name())) await requireConfig();
6268
});
@@ -81,5 +87,6 @@ registerTicketsCommand(program);
8187
registerInstallClaudeSkillCommand(program);
8288
registerShellInitCommand(program);
8389
registerWorktreeCommand(program);
90+
registerCompletionsCommand(program);
8491

8592
program.parseAsync(process.argv).catch(handleError);

tests/completions.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { vi, describe, it, expect, beforeEach } from 'vitest';
2+
import type { Branch, BranchesFile } from '../src/config/schemas';
3+
4+
vi.mock('../src/utils/detect', () => ({
5+
requireTrackedRepo: vi.fn(),
6+
}));
7+
8+
vi.mock('../src/config/manager', () => ({
9+
configManager: {
10+
getBranches: vi.fn(),
11+
},
12+
}));
13+
14+
import { requireTrackedRepo } from '../src/utils/detect';
15+
import { configManager } from '../src/config/manager';
16+
17+
const mockRequireTrackedRepo = requireTrackedRepo as ReturnType<typeof vi.fn>;
18+
const mockGetBranches = configManager.getBranches as ReturnType<typeof vi.fn>;
19+
20+
function makeBranch(overrides: Partial<Branch>): Branch {
21+
return {
22+
id: 'test-id',
23+
branchName: 'feature/test',
24+
ticketId: null,
25+
ticketTitle: null,
26+
ticketUrl: null,
27+
status: 'active',
28+
prNumber: null,
29+
prUrl: null,
30+
prStatus: null,
31+
worktreePath: null,
32+
createdAt: new Date().toISOString(),
33+
updatedAt: new Date().toISOString(),
34+
lastAccessedAt: new Date().toISOString(),
35+
...overrides,
36+
};
37+
}
38+
39+
// Dynamically import the module to get the action handler
40+
async function runCompletions(type?: string): Promise<string> {
41+
const { registerCompletionsCommand } = await import('../src/commands/completions');
42+
const { Command } = await import('commander');
43+
const program = new Command();
44+
registerCompletionsCommand(program);
45+
46+
const output: string[] = [];
47+
const origLog = console.log;
48+
console.log = (...args: unknown[]) => output.push(String(args[0]));
49+
50+
try {
51+
await program.parseAsync(['node', 'test', '_completions', ...(type ? [type] : [])]);
52+
} finally {
53+
console.log = origLog;
54+
}
55+
56+
return output.join('\n');
57+
}
58+
59+
describe('_completions command', () => {
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
mockRequireTrackedRepo.mockResolvedValue('project-123');
63+
});
64+
65+
describe('branches type', () => {
66+
it('outputs active and pr_open branch names', async () => {
67+
const branchesFile: BranchesFile = {
68+
version: 1,
69+
branches: [
70+
makeBranch({ branchName: 'feature/one', status: 'active' }),
71+
makeBranch({ branchName: 'feature/two', status: 'pr_open' }),
72+
makeBranch({ branchName: 'feature/done', status: 'done' }),
73+
makeBranch({ branchName: 'feature/abandoned', status: 'abandoned' }),
74+
],
75+
};
76+
mockGetBranches.mockResolvedValue(branchesFile);
77+
78+
const output = await runCompletions('branches');
79+
expect(output).toBe('feature/one\nfeature/two');
80+
});
81+
82+
it('outputs nothing when no branches exist', async () => {
83+
mockGetBranches.mockResolvedValue({ version: 1, branches: [] });
84+
85+
const output = await runCompletions('branches');
86+
expect(output).toBe('');
87+
});
88+
});
89+
90+
describe('tickets type', () => {
91+
it('outputs deduplicated ticket IDs', async () => {
92+
const branchesFile: BranchesFile = {
93+
version: 1,
94+
branches: [
95+
makeBranch({ branchName: 'feature/one', ticketId: 'MORG-1' }),
96+
makeBranch({ branchName: 'feature/two', ticketId: 'MORG-2' }),
97+
makeBranch({ branchName: 'feature/three', ticketId: 'MORG-1' }),
98+
makeBranch({ branchName: 'feature/no-ticket', ticketId: null }),
99+
],
100+
};
101+
mockGetBranches.mockResolvedValue(branchesFile);
102+
103+
const output = await runCompletions('tickets');
104+
expect(output).toBe('MORG-1\nMORG-2');
105+
});
106+
});
107+
108+
describe('error handling', () => {
109+
it('outputs nothing when repo is not tracked', async () => {
110+
mockRequireTrackedRepo.mockRejectedValue(new Error('Not tracked'));
111+
112+
const output = await runCompletions('branches');
113+
expect(output).toBe('');
114+
});
115+
116+
it('outputs nothing when getBranches fails', async () => {
117+
mockGetBranches.mockRejectedValue(new Error('File not found'));
118+
119+
const output = await runCompletions('branches');
120+
expect(output).toBe('');
121+
});
122+
123+
it('outputs nothing for unknown type', async () => {
124+
mockGetBranches.mockResolvedValue({ version: 1, branches: [] });
125+
126+
const output = await runCompletions('unknown');
127+
expect(output).toBe('');
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)