// Copyright (C) 2025 Keygraph, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. import { $ } from 'zx'; import type { ActivityLogger } from '../types/activity-logger.js'; import { ErrorCode } from '../types/errors.js'; import { PentestError } from './error-handling.js'; /** * Check if a directory is a git repository. * Returns true if the directory contains a .git folder or is inside a git repo. */ export async function isGitRepository(dir: string): Promise { try { await $`cd ${dir} && git rev-parse --git-dir`.quiet(); return true; } catch { return false; } } interface GitOperationResult { success: boolean; hadChanges?: boolean; error?: Error; } /** * Get list of changed files from git status --porcelain output */ async function getChangedFiles(sourceDir: string, operationDescription: string): Promise { const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], sourceDir, operationDescription); return status.stdout .trim() .split('\n') .filter((line) => line.length > 0); } /** * Log a summary of changed files with truncation for long lists */ function logChangeSummary( changes: string[], messageWithChanges: string, messageWithoutChanges: string, logger: ActivityLogger, level: 'info' | 'warn' = 'info', maxToShow: number = 5, ): void { if (changes.length > 0) { const msg = messageWithChanges.replace('{count}', String(changes.length)); const fileList = changes .slice(0, maxToShow) .map((c) => ` ${c}`) .join(', '); const suffix = changes.length > maxToShow ? ` ... and ${changes.length - maxToShow} more files` : ''; logger[level](`${msg} ${fileList}${suffix}`); } else { logger[level](messageWithoutChanges); } } /** * Convert unknown error to GitOperationResult */ function toErrorResult(error: unknown): GitOperationResult { const errMsg = error instanceof Error ? error.message : String(error); return { success: false, error: error instanceof Error ? error : new Error(errMsg), }; } // Serializes git operations to prevent index.lock conflicts during parallel agent execution class GitSemaphore { private queue: Array<() => void> = []; private running: boolean = false; async acquire(): Promise { return new Promise((resolve) => { this.queue.push(resolve); this.process(); }); } release(): void { this.running = false; this.process(); } private process(): void { if (!this.running && this.queue.length > 0) { this.running = true; const resolve = this.queue.shift(); resolve?.(); } } } const gitSemaphore = new GitSemaphore(); const GIT_LOCK_ERROR_PATTERNS = [ 'index.lock', 'unable to lock', 'Another git process', 'fatal: Unable to create', 'fatal: index file', ]; function isGitLockError(errorMessage: string): boolean { return GIT_LOCK_ERROR_PATTERNS.some((pattern) => errorMessage.includes(pattern)); } // Retries git commands on lock conflicts with exponential backoff export async function executeGitCommandWithRetry( commandArgs: string[], sourceDir: string, description: string, maxRetries: number = 5, ): Promise<{ stdout: string; stderr: string }> { await gitSemaphore.acquire(); try { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const [cmd, ...args] = commandArgs; const result = await $`cd ${sourceDir} && ${cmd} ${args}`; return result; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); if (isGitLockError(errMsg) && attempt < maxRetries) { const delay = 2 ** (attempt - 1) * 1000; // executeGitCommandWithRetry is also called outside activity context // (e.g., from resume logic), so we use console.warn as a fallback here console.warn( `Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } throw new PentestError( `Git command failed after ${maxRetries} retries`, 'filesystem', true, // Retryable - transient git lock issues { maxRetries, description }, ErrorCode.GIT_CHECKPOINT_FAILED, ); } finally { gitSemaphore.release(); } } // Two-phase reset: hard reset (tracked files) + clean (untracked files) export async function rollbackGitWorkspace( sourceDir: string, reason: string = 'retry preparation', logger: ActivityLogger, ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { logger.info('Skipping git rollback (not a git repository)'); return { success: true }; } logger.info(`Rolling back workspace for ${reason}`); try { const changes = await getChangedFiles(sourceDir, 'status check for rollback'); await executeGitCommandWithRetry(['git', 'reset', '--hard', 'HEAD'], sourceDir, 'hard reset for rollback'); await executeGitCommandWithRetry(['git', 'clean', '-fd'], sourceDir, 'cleaning untracked files for rollback'); logChangeSummary( changes, 'Rollback completed - removed {count} contaminated changes:', 'Rollback completed - no changes to remove', logger, 'info', 3, ); return { success: true }; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); logger.error(`Rollback failed after retries: ${errMsg}`); return { success: false, error: new PentestError( `Git rollback failed: ${errMsg}`, 'filesystem', false, // Non-retryable - rollback is best-effort cleanup { sourceDir, reason }, ErrorCode.GIT_ROLLBACK_FAILED, ), }; } } // Creates checkpoint before each attempt. First attempt preserves workspace; retries clean it. export async function createGitCheckpoint( sourceDir: string, description: string, attempt: number, logger: ActivityLogger, ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { logger.info('Skipping git checkpoint (not a git repository)'); return { success: true }; } logger.info(`Creating checkpoint for ${description} (attempt ${attempt})`); try { // 1. On retries, clean workspace to prevent pollution from previous attempt if (attempt > 1) { const cleanResult = await rollbackGitWorkspace(sourceDir, `${description} (retry cleanup)`, logger); if (!cleanResult.success) { logger.warn(`Workspace cleanup failed, continuing anyway: ${cleanResult.error?.message}`); } } // 2. Detect existing changes const changes = await getChangedFiles(sourceDir, 'status check'); const hasChanges = changes.length > 0; // 3. Stage and commit checkpoint await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes'); await executeGitCommandWithRetry( ['git', 'commit', '-m', `📍 Checkpoint: ${description} (attempt ${attempt})`, '--allow-empty'], sourceDir, 'creating commit', ); // 4. Log result if (hasChanges) { logger.info('Checkpoint created with uncommitted changes staged'); } else { logger.info('Empty checkpoint created (no workspace changes)'); } return { success: true }; } catch (error) { const result = toErrorResult(error); logger.warn(`Checkpoint creation failed after retries: ${result.error?.message}`); return result; } } export async function commitGitSuccess( sourceDir: string, description: string, logger: ActivityLogger, ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { logger.info('Skipping git commit (not a git repository)'); return { success: true }; } logger.info(`Committing successful results for ${description}`); try { const changes = await getChangedFiles(sourceDir, 'status check for success commit'); await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes for success commit'); await executeGitCommandWithRetry( ['git', 'commit', '-m', `✅ ${description}: completed successfully`, '--allow-empty'], sourceDir, 'creating success commit', ); logChangeSummary( changes, 'Success commit created with {count} file changes:', 'Empty success commit created (agent made no file changes)', logger, ); return { success: true }; } catch (error) { const result = toErrorResult(error); logger.warn(`Success commit failed after retries: ${result.error?.message}`); return result; } } /** * Get current git commit hash. * Returns null if not a git repository. */ export async function getGitCommitHash(sourceDir: string): Promise { if (!(await isGitRepository(sourceDir))) { return null; } try { const result = await $`cd ${sourceDir} && git rev-parse HEAD`; return result.stdout.trim(); } catch { return null; } }