UNPKG

@aaronshaf/ger

Version:

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

343 lines (290 loc) 11.2 kB
import { Effect, Console, pipe, Layer, Context } from 'effect' import { Schema } from 'effect' import * as os from 'node:os' import * as path from 'node:path' import * as fs from 'node:fs/promises' import { spawn } from 'node:child_process' // Error types with explicit interfaces export interface WorktreeCreationErrorFields { readonly message: string readonly cause?: unknown } const WorktreeCreationErrorSchema = Schema.TaggedError<WorktreeCreationErrorFields>()( 'WorktreeCreationError', { message: Schema.String, cause: Schema.optional(Schema.Unknown), }, ) as unknown export class WorktreeCreationError extends (WorktreeCreationErrorSchema as new ( args: WorktreeCreationErrorFields, ) => WorktreeCreationErrorFields & Error & { readonly _tag: 'WorktreeCreationError' }) implements Error { readonly name = 'WorktreeCreationError' } export interface PatchsetFetchErrorFields { readonly message: string readonly cause?: unknown } const PatchsetFetchErrorSchema = Schema.TaggedError<PatchsetFetchErrorFields>()( 'PatchsetFetchError', { message: Schema.String, cause: Schema.optional(Schema.Unknown), }, ) as unknown export class PatchsetFetchError extends (PatchsetFetchErrorSchema as new ( args: PatchsetFetchErrorFields, ) => PatchsetFetchErrorFields & Error & { readonly _tag: 'PatchsetFetchError' }) implements Error { readonly name = 'PatchsetFetchError' } export interface DirtyRepoErrorFields { readonly message: string } const DirtyRepoErrorSchema = Schema.TaggedError<DirtyRepoErrorFields>()('DirtyRepoError', { message: Schema.String, }) as unknown export class DirtyRepoError extends (DirtyRepoErrorSchema as new ( args: DirtyRepoErrorFields, ) => DirtyRepoErrorFields & Error & { readonly _tag: 'DirtyRepoError' }) implements Error { readonly name = 'DirtyRepoError' } export interface NotGitRepoErrorFields { readonly message: string } const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', { message: Schema.String, }) as unknown export class NotGitRepoError extends (NotGitRepoErrorSchema as new ( args: NotGitRepoErrorFields, ) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' }) implements Error { readonly name = 'NotGitRepoError' } export type GitWorktreeError = | WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError // Worktree info export interface WorktreeInfo { path: string changeId: string originalCwd: string timestamp: number pid: number } // Git command runner with Effect const runGitCommand = ( args: string[], options: { cwd?: string } = {}, ): Effect.Effect<string, GitWorktreeError, never> => Effect.async<string, GitWorktreeError, never>((resume) => { const child = spawn('git', args, { cwd: options.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' child.stdout?.on('data', (data) => { stdout += data.toString() }) child.stderr?.on('data', (data) => { stderr += data.toString() }) child.on('close', (code) => { if (code === 0) { resume(Effect.succeed(stdout.trim())) } else { const errorMessage = `Git command failed: git ${args.join(' ')}\nStderr: ${stderr}` // Classify error based on command and output if (args[0] === 'worktree' && args[1] === 'add') { resume(Effect.fail(new WorktreeCreationError({ message: errorMessage }))) } else if (args[0] === 'fetch' || args[0] === 'checkout') { resume(Effect.fail(new PatchsetFetchError({ message: errorMessage }))) } else { resume(Effect.fail(new WorktreeCreationError({ message: errorMessage }))) } } }) child.on('error', (error) => { resume( Effect.fail( new WorktreeCreationError({ message: `Failed to spawn git: ${error.message}`, cause: error, }), ), ) }) }) // Check if current directory is a git repository const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> => pipe( runGitCommand(['rev-parse', '--git-dir']), Effect.mapError( () => new NotGitRepoError({ message: 'Current directory is not a git repository' }), ), Effect.map(() => undefined), ) // Generate unique worktree path const generateWorktreePath = (changeId: string): string => { const timestamp = Date.now() const pid = process.pid const uniqueId = `${changeId}-${timestamp}-${pid}` return path.join(os.homedir(), '.ger', 'worktrees', uniqueId) } // Ensure .ger directory exists const ensureGerDirectory = (): Effect.Effect<void, never, never> => Effect.tryPromise({ try: async () => { const gerDir = path.join(os.homedir(), '.ger', 'worktrees') await fs.mkdir(gerDir, { recursive: true }) }, catch: () => undefined, // Ignore errors, will fail later if directory can't be created }).pipe(Effect.catchAll(() => Effect.succeed(undefined))) // Build Gerrit refspec for change const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string => { // Extract change number from changeId if it contains non-numeric characters const numericChangeNumber = changeNumber.replace(/\D/g, '') return `refs/changes/${numericChangeNumber.slice(-2)}/${numericChangeNumber}/${patchsetNumber}` } // Get the current HEAD commit hash to avoid branch conflicts const getCurrentCommit = (): Effect.Effect<string, GitWorktreeError, never> => pipe( runGitCommand(['rev-parse', 'HEAD']), Effect.map((output) => output.trim()), Effect.catchAll(() => // Fallback: try to get commit from default branch pipe( runGitCommand(['rev-parse', 'origin/main']), Effect.catchAll(() => runGitCommand(['rev-parse', 'origin/master'])), Effect.catchAll(() => Effect.succeed('HEAD')), ), ), ) // Get latest patchset number for a change const getLatestPatchsetNumber = ( changeId: string, ): Effect.Effect<number, PatchsetFetchError, never> => pipe( runGitCommand(['ls-remote', 'origin', `refs/changes/*/${changeId.replace(/\D/g, '')}/*`]), Effect.mapError( (error) => new PatchsetFetchError({ message: `Failed to get patchset info: ${error.message}` }), ), Effect.map((output) => { const lines = output.split('\n').filter((line) => line.trim()) if (lines.length === 0) { return 1 // Default to patchset 1 if no refs found } // Extract patchset numbers and return the highest const patchsetNumbers = lines .map((line) => { const match = line.match(/refs\/changes\/\d+\/\d+\/(\d+)$/) return match ? parseInt(match[1], 10) : 0 }) .filter((num) => num > 0) return patchsetNumbers.length > 0 ? Math.max(...patchsetNumbers) : 1 }), ) // GitWorktreeService implementation export interface GitWorktreeServiceImpl { validatePreconditions: () => Effect.Effect<void, GitWorktreeError, never> createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitWorktreeError, never> fetchAndCheckoutPatchset: ( worktreeInfo: WorktreeInfo, ) => Effect.Effect<void, GitWorktreeError, never> cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never> getChangedFiles: () => Effect.Effect<string[], GitWorktreeError, never> } const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = { validatePreconditions: () => Effect.gen(function* () { yield* validateGitRepo() yield* Console.log('✓ Git repository validation passed') }), createWorktree: (changeId: string) => Effect.gen(function* () { yield* Console.log(`→ Creating worktree for change ${changeId}...`) // Get current commit hash to avoid branch conflicts const currentCommit = yield* getCurrentCommit() yield* Console.log(`→ Using base commit: ${currentCommit.substring(0, 7)}`) // Ensure .ger directory exists yield* ensureGerDirectory() // Generate unique path const worktreePath = generateWorktreePath(changeId) const originalCwd = process.cwd() // Create worktree using commit hash (no branch conflicts) yield* runGitCommand(['worktree', 'add', '--detach', worktreePath, currentCommit]) const worktreeInfo: WorktreeInfo = { path: worktreePath, changeId, originalCwd, timestamp: Date.now(), pid: process.pid, } yield* Console.log(`✓ Worktree created at ${worktreePath}`) return worktreeInfo }), fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) => Effect.gen(function* () { yield* Console.log(`→ Fetching and checking out patchset for ${worktreeInfo.changeId}...`) // Get latest patchset number const patchsetNumber = yield* getLatestPatchsetNumber(worktreeInfo.changeId) const refspec = buildRefspec(worktreeInfo.changeId, patchsetNumber) yield* Console.log(`→ Using refspec: ${refspec}`) // Fetch the change yield* runGitCommand(['fetch', 'origin', refspec], { cwd: worktreeInfo.path }) // Checkout FETCH_HEAD yield* runGitCommand(['checkout', 'FETCH_HEAD'], { cwd: worktreeInfo.path }) yield* Console.log(`✓ Checked out patchset ${patchsetNumber} for ${worktreeInfo.changeId}`) }), cleanup: (worktreeInfo: WorktreeInfo) => Effect.gen(function* () { yield* Console.log(`→ Cleaning up worktree for ${worktreeInfo.changeId}...`) // Always restore original working directory first try { process.chdir(worktreeInfo.originalCwd) } catch (error) { yield* Console.warn(`Warning: Could not restore original directory: ${error}`) } // Attempt to remove worktree (don't fail if this doesn't work) yield* pipe( runGitCommand(['worktree', 'remove', '--force', worktreeInfo.path]), Effect.catchAll((error) => Effect.gen(function* () { yield* Console.warn(`Warning: Could not remove worktree: ${error.message}`) yield* Console.warn(`Manual cleanup may be required: ${worktreeInfo.path}`) }), ), ) yield* Console.log(`✓ Cleanup completed for ${worktreeInfo.changeId}`) }), getChangedFiles: () => Effect.gen(function* () { // Get list of changed files in current worktree const output = yield* runGitCommand(['diff', '--name-only', 'HEAD~1']) const files = output.split('\n').filter((file) => file.trim()) return files }), } // Export service tag for dependency injection with explicit type export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> = Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService') export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService> // Export service layer with explicit type export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed( GitWorktreeService, GitWorktreeServiceImplLive, )