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
72 changes: 39 additions & 33 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 {};
165 changes: 131 additions & 34 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}

async function findCompletionProjectRoot(
startPath = process.cwd()
): Promise<string | null> {
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<WorktreeEntry[]> {
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, ' ');
}
Expand All @@ -37,43 +140,37 @@ function formatCompletionCandidate(candidate: CompletionCandidate): string {
return description ? `${value}\t${description}` : value;
}

export async function runCompletion(rawWords: string[]): Promise<void> {
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 })
.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.
}
await runCompletion(normalizeVariadicWords(words));
});
}
Loading