@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
423 lines (369 loc) • 13.5 kB
text/typescript
import { execSync } from 'node:child_process'
import { Console, Effect, Schema } from 'effect'
import chalk from 'chalk'
import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
import { extractChangeNumber } from '@/utils/url-parser'
/** Help text for checkout command */
export const CHECKOUT_HELP_TEXT = `
Examples:
# Checkout latest patchset
$ ger checkout 12345
# Checkout specific patchset
$ ger checkout 12345/3
# Checkout by Change-ID
$ ger checkout If5a3ae8cb5a107e187447802358417f311d0c4b1
# Checkout from URL
$ ger checkout https://gerrit.example.com/c/my-project/+/392385
# Detached HEAD mode (for quick review)
$ ger checkout 12345 --detach
# Use specific remote
$ ger checkout 12345 --remote upstream
Notes:
- Creates/updates branch named review/<change-number>
- Sets upstream tracking to target branch
- Updates existing review branch if it exists`
export interface CheckoutOptions {
detach?: boolean
remote?: string
}
// Custom error for checkout-specific failures
export class CheckoutError extends Error {
readonly _tag = 'CheckoutError'
constructor(message: string) {
super(message)
this.name = 'CheckoutError'
}
}
export class NotGitRepoError extends Error {
readonly _tag = 'NotGitRepoError'
constructor(message: string) {
super(message)
this.name = 'NotGitRepoError'
}
}
export class PatchsetNotFoundError extends Error {
readonly _tag = 'PatchsetNotFoundError'
constructor(public readonly patchset: number) {
super(`Patchset ${patchset} not found`)
this.name = 'PatchsetNotFoundError'
}
}
export class InvalidInputError extends Error {
readonly _tag = 'InvalidInputError'
constructor(message: string) {
super(message)
this.name = 'InvalidInputError'
}
}
export type CheckoutErrors =
| ConfigError
| CheckoutError
| NotGitRepoError
| PatchsetNotFoundError
| ApiError
| InvalidInputError
// Git-safe string validation - prevents command injection
// Allows alphanumeric, hyphens, underscores, slashes, and dots
const GitSafeString = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9_\-/.]+$/),
Schema.annotations({ message: () => 'Invalid characters in git identifier' }),
)
// Gerrit ref validation (refs/changes/xx/xxxxx/x)
const GerritRef = Schema.String.pipe(
Schema.pattern(/^refs\/changes\/\d{2}\/\d+\/\d+$/),
Schema.annotations({ message: () => 'Invalid Gerrit ref format' }),
)
// Validate git-safe strings to prevent command injection
const validateGitSafe = (
value: string,
fieldName: string,
): Effect.Effect<string, InvalidInputError> =>
Schema.decodeUnknown(GitSafeString)(value).pipe(
Effect.mapError(() => {
// Sanitize error message to avoid exposing potentially sensitive data
const sanitized = value.length > 20 ? `${value.substring(0, 20)}...` : value
return new InvalidInputError(`${fieldName} contains invalid characters: ${sanitized}`)
}),
)
const validateGerritRef = (value: string): Effect.Effect<string, InvalidInputError> =>
Schema.decodeUnknown(GerritRef)(value).pipe(
Effect.mapError(() => {
// Sanitize error message to avoid exposing potentially sensitive data
const sanitized = value.length > 30 ? `${value.substring(0, 30)}...` : value
return new InvalidInputError(`Invalid Gerrit ref format: ${sanitized}`)
}),
)
/** Parse change input to extract change ID and optional patchset */
interface ParsedChange {
changeId: string
patchset?: number
}
export const parseChangeInput = (input: string): ParsedChange => {
const trimmed = input.trim()
// 1. If it's a URL, extract change number and check for patchset
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const changeId = extractChangeNumber(trimmed)
// Try to extract patchset from URL path: /c/project/+/12345/3
const patchsetMatch = trimmed.match(/\/(\d+)\/(\d+)(?:\/|$)/)
if (patchsetMatch?.[2]) {
return {
changeId: patchsetMatch[1],
patchset: parseInt(patchsetMatch[2], 10),
}
}
return { changeId }
}
// 2. Check for change/patchset format: 12345/3
if (trimmed.includes('/') && !trimmed.startsWith('http')) {
const parts = trimmed.split('/')
if (parts.length === 2) {
const [changeId, patchsetStr] = parts
const patchset = parseInt(patchsetStr, 10)
if (!Number.isNaN(patchset) && patchset > 0) {
return { changeId, patchset }
}
// If patchset is invalid, just return the changeId part
return { changeId }
}
}
// 3. Plain change number or Change-ID
return { changeId: trimmed }
}
// Get git remotes
const getGitRemotes = (): Record<string, string> => {
try {
const output = execSync('git remote -v', { encoding: 'utf8', timeout: 5000 })
const remotes: Record<string, string> = {}
for (const line of output.split('\n')) {
const match = line.match(/^(\S+)\s+(\S+)\s+\(push\)$/)
if (match) {
remotes[match[1]] = match[2]
}
}
return remotes
} catch {
// Silently return empty object - remote detection is optional
return {}
}
}
// Find remote matching Gerrit host
const findMatchingRemote = (gerritHost: string): string | null => {
const remotes = getGitRemotes()
// Parse gerrit host
const gerritUrl = new URL(gerritHost)
const gerritHostname = gerritUrl.hostname
// Check each remote
for (const [name, url] of Object.entries(remotes)) {
try {
let remoteHostname: string
if (url.startsWith('git@') || url.includes('://')) {
if (url.startsWith('git@')) {
// SSH format: git@hostname:project
remoteHostname = url.split('@')[1].split(':')[0]
} else {
// HTTP format
const remoteUrl = new URL(url)
remoteHostname = remoteUrl.hostname
}
if (remoteHostname === gerritHostname) {
return name
}
}
} catch {
// Ignore malformed URLs
}
}
return null
}
// Check if we're in a git repo
const isInGitRepo = (): boolean => {
try {
execSync('git rev-parse --git-dir', { encoding: 'utf8', timeout: 5000 })
return true
} catch {
return false
}
}
// Get current branch name
const getCurrentBranch = (): string | null => {
try {
const branch = execSync('git symbolic-ref --short HEAD', {
encoding: 'utf8',
timeout: 5000,
}).trim()
return branch || null
} catch {
return null
}
}
// Check if a local branch exists (internal helper using Effect pattern)
const localBranchExists = (branchName: string): Effect.Effect<boolean, InvalidInputError> =>
validateGitSafe(branchName, 'branch name').pipe(
Effect.flatMap((validated) =>
Effect.sync(() => {
try {
execSync(`git rev-parse --verify ${validated}`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
})
return true
} catch {
// Branch doesn't exist is expected, return false
return false
}
}),
),
)
export const checkoutCommand = (
changeInput: string,
options: CheckoutOptions,
): Effect.Effect<void, CheckoutErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
Effect.gen(function* () {
// 1. Parse input
const parsed = parseChangeInput(changeInput)
// 2. Verify git repo
if (!isInGitRepo()) {
return yield* Effect.fail(new NotGitRepoError('Not in a git repository'))
}
// 3. Get config and API service
const configService = yield* ConfigService
const apiService = yield* GerritApiService
const credentials = yield* configService.getCredentials
// 4. Get change details
const change = yield* apiService.getChange(parsed.changeId)
// 5. Get revision details - use from change if available, otherwise fetch separately
const revision = yield* Effect.gen(function* () {
// If requesting a specific patchset, always fetch it
if (parsed.patchset) {
const patchsetNum = parsed.patchset
if (patchsetNum === undefined) {
return yield* Effect.fail(
new InvalidInputError('Patchset number is required but was undefined'),
)
}
return yield* apiService
.getRevision(parsed.changeId, patchsetNum.toString())
.pipe(Effect.catchAll(() => Effect.fail(new PatchsetNotFoundError(patchsetNum))))
}
// For current revision, use it from change response if available
if (change.current_revision && change.revisions) {
const currentRevision = change.revisions[change.current_revision]
if (currentRevision) {
return currentRevision
}
}
// Fallback to fetching revision separately
return yield* apiService.getRevision(parsed.changeId, 'current')
})
// 6. Validate inputs before using in shell commands
const validatedRef = yield* validateGerritRef(revision.ref)
// 7. Find matching remote and validate
const rawRemote = options.remote || findMatchingRemote(credentials.host) || 'origin'
const remote = yield* validateGitSafe(rawRemote, 'remote')
// 8. Determine branch name and validate
const rawBranchName = `review/${change._number}`
const branchName = yield* validateGitSafe(rawBranchName, 'branch name')
const currentBranch = getCurrentBranch()
const branchExists = yield* localBranchExists(branchName)
// 9. Validate target branch for upstream tracking
const targetBranch = yield* validateGitSafe(change.branch, 'target branch')
// 10. Display information
yield* Console.log(chalk.bold('Checking out Gerrit change'))
yield* Console.log(` Change: ${change._number} - ${change.subject}`)
yield* Console.log(` Patchset: ${revision._number}`)
yield* Console.log(` Status: ${change.status}`)
yield* Console.log(` Branch: ${branchName}`)
yield* Console.log(` Remote: ${remote}`)
yield* Console.log('')
// 11. Fetch the change (using validated inputs)
yield* Console.log(chalk.cyan(`Fetching ${validatedRef}...`))
yield* Effect.try({
try: () => {
execSync(`git fetch ${remote} ${validatedRef}`, { stdio: 'inherit', timeout: 60000 })
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to fetch change from remote: ${errorMsg}`)
},
})
// 12. Checkout/update branch
if (options.detach) {
// Detached HEAD mode
yield* Effect.try({
try: () => {
execSync('git checkout FETCH_HEAD', { stdio: 'inherit', timeout: 30000 })
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to checkout in detached HEAD mode: ${errorMsg}`)
},
})
yield* Console.log(chalk.green('Checked out in detached HEAD mode'))
} else {
// Named branch mode
if (branchExists) {
// Update existing branch
if (currentBranch !== branchName) {
yield* Effect.try({
try: () => {
execSync(`git checkout ${branchName}`, { stdio: 'inherit', timeout: 30000 })
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to switch to branch: ${errorMsg}`)
},
})
}
yield* Effect.try({
try: () => {
execSync('git reset --hard FETCH_HEAD', { stdio: 'inherit', timeout: 30000 })
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to update branch: ${errorMsg}`)
},
})
yield* Console.log(chalk.green(`Updated and checked out ${branchName}`))
} else {
// Create new branch
yield* Effect.try({
try: () => {
execSync(`git checkout -b ${branchName} FETCH_HEAD`, {
stdio: 'inherit',
timeout: 30000,
})
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to create branch: ${errorMsg}`)
},
})
yield* Console.log(chalk.green(`Created and checked out ${branchName}`))
}
// 13. Set upstream tracking
const upstreamRef = `${remote}/${targetBranch}`
yield* Effect.try({
try: () => {
execSync(`git branch --set-upstream-to=${upstreamRef} ${branchName}`, {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
})
},
catch: (e) => {
const errorMsg = e instanceof Error ? e.message : String(e)
return new CheckoutError(`Failed to set upstream tracking: ${errorMsg}`)
},
}).pipe(
Effect.flatMap(() => Console.log(`Tracking ${upstreamRef}`)),
Effect.catchAll(() =>
Console.log(chalk.yellow(`Note: Could not set upstream tracking to ${upstreamRef}`)),
),
)
}
yield* Console.log('')
yield* Console.log(
chalk.cyan(`Change URL: ${credentials.host}/c/${change.project}/+/${change._number}`),
)
})