UNPKG

@aaronshaf/ger

Version:

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

169 lines (147 loc) 5.44 kB
import { spawnSync, execSync } from 'node:child_process' import * as path from 'node:path' import { Effect } from 'effect' import chalk from 'chalk' import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config' export interface TreeRebaseOptions { onto?: string interactive?: boolean xml?: boolean json?: boolean } const isInGitRepo = (): boolean => { try { execSync('git rev-parse --git-dir', { encoding: 'utf8' }) return true } catch { return false } } const getRepoRoot = (): string => execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim() const getCwd = (): string => process.cwd() /** Returns the remote name for the given Gerrit host, or null if not found. */ const findMatchingRemote = (repoRoot: string, gerritHost: string): string | null => { try { const output = execSync('git remote -v', { encoding: 'utf8', cwd: repoRoot }) const gerritHostname = new URL(gerritHost).hostname for (const line of output.split('\n')) { const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/) if (!match) continue const url = match[2] try { let remoteHostname: string if (url.startsWith('git@')) { remoteHostname = url.split('@')[1].split(':')[0] } else { remoteHostname = new URL(url).hostname } if (remoteHostname === gerritHostname) return match[1] } catch { // ignore malformed URLs } } } catch { // ignore } return null } const detectBaseBranch = (remote: string): string => { // Prefer upstream tracking branch try { const upstream = execSync('git rev-parse --abbrev-ref HEAD@{u}', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim() if (upstream && !upstream.includes('@{u}')) return upstream } catch { // no upstream set } // Fall back to <remote>/main, then <remote>/master for (const branch of [`${remote}/main`, `${remote}/master`]) { try { execSync(`git rev-parse --verify ${branch}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) return branch } catch { // try next } } return `${remote}/main` } /** Verify the current directory is inside a ger-managed worktree (.ger/<number>). */ const assertInGerWorktree = (repoRoot: string): void => { const cwd = getCwd() const gerDir = path.join(repoRoot, '.ger') + path.sep if (!cwd.startsWith(gerDir)) { throw new Error( `Not inside a ger-managed worktree.\nRun "ger tree setup <change-id>" first, then cd into the worktree.`, ) } // The segment after .ger/ should be a numeric change number const rel = cwd.slice(gerDir.length) const changeNum = rel.split(path.sep)[0] if (!/^\d+$/.test(changeNum)) { throw new Error(`Current directory does not look like a ger worktree: ${cwd}`) } } export const treeRebaseCommand = ( options: TreeRebaseOptions, // Optional: override gerrit host for testing _gerritHostOverride?: string, ): Effect.Effect<void, Error | ConfigError, ConfigServiceImpl> => Effect.gen(function* () { const configService = yield* ConfigService const credentials = yield* configService.getCredentials.pipe(Effect.mapError((e): Error => e)) const gerritHost = _gerritHostOverride ?? credentials.host if (!isInGitRepo()) { throw new Error('Not in a git repository') } const repoRoot = getRepoRoot() // Only allow running from inside a ger-managed worktree assertInGerWorktree(repoRoot) // Resolve the correct remote matching the configured Gerrit host const remote = findMatchingRemote(repoRoot, gerritHost) ?? 'origin' yield* Effect.try({ try: () => { const baseBranch = options.onto ?? detectBaseBranch(remote) if (!options.xml && !options.json) { console.log(chalk.bold(`Rebasing onto ${chalk.cyan(baseBranch)}...`)) console.log(chalk.dim(` Fetching ${remote}...`)) } const fetchResult = spawnSync('git', ['fetch', remote], { encoding: 'utf8', cwd: repoRoot }) if (fetchResult.status !== 0) { throw new Error(`Failed to fetch ${remote}: ${fetchResult.stderr}`) } if (!options.xml && !options.json) { console.log(chalk.dim(` Running git rebase ${baseBranch}...`)) } const rebaseArgs = options.interactive ? ['rebase', '-i', baseBranch] : ['rebase', baseBranch] const rebaseResult = spawnSync('git', rebaseArgs, { encoding: 'utf8', stdio: 'inherit', }) if (rebaseResult.status !== 0) { throw new Error( `Rebase failed. Resolve conflicts then run:\n git rebase --continue\nor abort with:\n git rebase --abort`, ) } if (options.json) { console.log(JSON.stringify({ status: 'success', base: baseBranch }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<tree_rebase>`) console.log(` <status>success</status>`) console.log(` <base><![CDATA[${baseBranch}]]></base>`) console.log(`</tree_rebase>`) } else { console.log(chalk.green('\n ✓ Rebase complete')) } }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), }) })