UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

203 lines (176 loc) 6.28 kB
import { spawnSync, execSync } from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' import { Effect } from 'effect' import chalk from 'chalk' import { type ApiError, GerritApiService, type GerritApiServiceImpl } from '@/api/gerrit' import { type ConfigError, ConfigService, type ConfigServiceImpl } from '@/services/config' export const TREE_SETUP_HELP_TEXT = ` Examples: # Set up worktree for latest patchset $ ger tree setup 12345 # Set up worktree for specific patchset $ ger tree setup 12345:3 # XML output (for LLM pipelines) $ ger tree setup 12345 --xml Notes: - Worktree is created at <repo-root>/.ger/<change-number>/ - If worktree already exists, prints the path and exits - Use 'ger trees' to list worktrees, 'ger tree cleanup' to remove them` export interface TreeSetupOptions { xml?: boolean json?: boolean } const parseChangeSpec = (changeSpec: string): { changeId: string; patchset?: string } => { const parts = changeSpec.split(':') return { changeId: parts[0], patchset: parts[1] } } const getGitRemotes = (): Record<string, string> => { try { const output = execSync('git remote -v', { encoding: 'utf8' }) const remotes: Record<string, string> = {} for (const line of output.split('\n')) { const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/) if (match) remotes[match[1]] = match[2] } return remotes } catch { return {} } } const findMatchingRemote = (gerritHost: string): string | null => { const remotes = getGitRemotes() const gerritUrl = new URL(gerritHost) const gerritHostname = gerritUrl.hostname for (const [name, url] of Object.entries(remotes)) { try { let remoteHostname: string if (url.startsWith('git@')) { remoteHostname = url.split('@')[1].split(':')[0] } else if (url.includes('://')) { remoteHostname = new URL(url).hostname } else { continue } if (remoteHostname === gerritHostname) return name } catch { // ignore malformed URLs } } return null } const isInGitRepo = (): boolean => { try { execSync('git rev-parse --git-dir', { encoding: 'utf8' }) return true } catch { return false } } const getRepoRoot = (): string => { try { return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim() } catch { throw new Error('Not in a git repository') } } export const treeSetupCommand = ( changeSpec: string, options: TreeSetupOptions, ): Effect.Effect<void, ApiError | ConfigError | Error, GerritApiServiceImpl | ConfigServiceImpl> => Effect.gen(function* () { if (!isInGitRepo()) { throw new Error( 'Not in a git repository. Please run this command from within a git repository.', ) } const repoRoot = getRepoRoot() const { changeId, patchset } = parseChangeSpec(changeSpec) const configService = yield* ConfigService const credentials = yield* configService.getCredentials const matchingRemote = findMatchingRemote(credentials.host) if (!matchingRemote) { throw new Error(`No git remote found matching Gerrit host: ${credentials.host}`) } if (!options.xml && !options.json) { console.log(chalk.bold(`Setting up worktree for change ${chalk.cyan(changeId)}...`)) } const gerritApi = yield* GerritApiService const change = yield* gerritApi.getChange(changeId) if (!options.xml && !options.json) { console.log(chalk.dim(` ${change._number}: ${change.subject}`)) } const targetPatchset = patchset ?? 'current' const revision = yield* gerritApi.getRevision(changeId, targetPatchset) const workspaceName = change._number.toString() if (!/^\d+$/.test(workspaceName)) { throw new Error(`Invalid change number: ${workspaceName}`) } const worktreeDir = path.join(repoRoot, '.ger', workspaceName) if (fs.existsSync(worktreeDir)) { if (options.json) { console.log(JSON.stringify({ status: 'success', path: worktreeDir, exists: true }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<tree_setup>`) console.log(` <path>${worktreeDir}</path>`) console.log(` <exists>true</exists>`) console.log(`</tree_setup>`) } else { console.log(chalk.yellow(' Worktree already exists')) console.log(`\n ${chalk.bold('cd')} ${chalk.green(worktreeDir)}`) } return } const gerDir = path.join(repoRoot, '.ger') if (!fs.existsSync(gerDir)) { fs.mkdirSync(gerDir, { recursive: true }) } const changeRef = revision.ref if (!options.xml && !options.json) { console.log(chalk.dim(` Fetching ${changeRef}...`)) } const fetchResult = spawnSync('git', ['fetch', matchingRemote, changeRef], { encoding: 'utf8', cwd: repoRoot, }) if (fetchResult.status !== 0) { throw new Error(fetchResult.stderr ?? 'Git fetch failed') } if (!options.xml && !options.json) { console.log(chalk.dim(` Creating worktree...`)) } const worktreeResult = spawnSync('git', ['worktree', 'add', worktreeDir, 'FETCH_HEAD'], { encoding: 'utf8', cwd: repoRoot, }) if (worktreeResult.status !== 0) { throw new Error(worktreeResult.stderr ?? 'Git worktree add failed') } if (options.json) { console.log( JSON.stringify( { status: 'success', path: worktreeDir, change_number: change._number, subject: change.subject, created: true, }, null, 2, ), ) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<tree_setup>`) console.log(` <path>${worktreeDir}</path>`) console.log(` <change_number>${change._number}</change_number>`) console.log(` <subject><![CDATA[${change.subject}]]></subject>`) console.log(` <created>true</created>`) console.log(`</tree_setup>`) } else { console.log(chalk.green('\n ✓ Worktree ready')) console.log(`\n ${chalk.bold('cd')} ${chalk.green(worktreeDir)}`) } })