/** * Shared git command execution utilities. * * This module provides the canonical `execGitCommand` helper and common * git utilities used across services and routes. All consumers should * import from here rather than defining their own copy. */ import fs from 'fs/promises'; import path from 'path'; import { spawnProcess } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; const logger = createLogger('GitLib'); // Extended PATH so git is found when the process does not inherit a full shell PATH // (e.g. Electron, some CI, or IDE-launched processes). const pathSeparator = process.platform === 'win32' ? ';' : ':'; const extraPaths: string[] = process.platform === 'win32' ? ([ process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`, process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`, process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`, ].filter(Boolean) as string[]) : [ '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/home/linuxbrew/.linuxbrew/bin', process.env.HOME ? `${process.env.HOME}/.local/bin` : '', ].filter(Boolean); const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator); const gitEnv = { ...process.env, PATH: extendedPath }; // ============================================================================ // Secure Command Execution // ============================================================================ /** * Execute git command with array arguments to prevent command injection. * Uses spawnProcess from @automaker/platform for secure, cross-platform execution. * * @param args - Array of git command arguments (e.g., ['worktree', 'add', path]) * @param cwd - Working directory to execute the command in * @param env - Optional additional environment variables to pass to the git process. * These are merged on top of the current process environment. Pass * `{ LC_ALL: 'C' }` to force git to emit English output regardless of the * system locale so that text-based output parsing remains reliable. * @param abortController - Optional AbortController to cancel the git process. * When the controller is aborted the underlying process is sent SIGTERM and * the returned promise rejects with an Error whose message is 'Process aborted'. * @returns Promise resolving to stdout output * @throws Error with stderr/stdout message if command fails. The thrown error * also has `stdout` and `stderr` string properties for structured access. * * @example * ```typescript * // Safe: no injection possible * await execGitCommand(['branch', '-D', branchName], projectPath); * * // Force English output for reliable text parsing: * await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' }); * * // With a process-level timeout: * const controller = new AbortController(); * const timerId = setTimeout(() => controller.abort(), 30_000); * try { * await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); * } finally { * clearTimeout(timerId); * } * * // Instead of unsafe: * // await execAsync(`git branch -D ${branchName}`, { cwd }); * ``` */ export async function execGitCommand( args: string[], cwd: string, env?: Record, abortController?: AbortController ): Promise { const result = await spawnProcess({ command: 'git', args, cwd, env: env !== undefined ? { ...gitEnv, ...env, PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator), } : gitEnv, ...(abortController !== undefined ? { abortController } : {}), }); // spawnProcess returns { stdout, stderr, exitCode } if (result.exitCode === 0) { return result.stdout; } else { const errorMessage = result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`; throw Object.assign(new Error(errorMessage), { stdout: result.stdout, stderr: result.stderr, }); } } // ============================================================================ // Common Git Utilities // ============================================================================ /** * Get the current branch name for the given worktree. * * This is the canonical implementation shared across services. Services * should import this rather than duplicating the logic locally. * * @param worktreePath - Path to the git worktree * @returns The current branch name (trimmed) */ export async function getCurrentBranch(worktreePath: string): Promise { const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); return branchOutput.trim(); } // ============================================================================ // Index Lock Recovery // ============================================================================ /** * Check whether an error message indicates a stale git index lock file. * * Git operations that write to the index (e.g. `git stash push`) will fail * with "could not write index" or "Unable to create ... .lock" when a * `.git/index.lock` file exists from a previously interrupted operation. * * @param errorMessage - The error string from a failed git command * @returns true if the error looks like a stale index lock issue */ export function isIndexLockError(errorMessage: string): boolean { const lower = errorMessage.toLowerCase(); return ( lower.includes('could not write index') || (lower.includes('unable to create') && lower.includes('index.lock')) || lower.includes('index.lock') ); } /** * Attempt to remove a stale `.git/index.lock` file for the given worktree. * * Uses `git rev-parse --git-dir` to locate the correct `.git` directory, * which works for both regular repositories and linked worktrees. * * @param worktreePath - Path to the git worktree (or main repo) * @returns true if a lock file was found and removed, false otherwise */ export async function removeStaleIndexLock(worktreePath: string): Promise { try { // Resolve the .git directory (handles worktrees correctly) const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); const lockFilePath = path.join(gitDir, 'index.lock'); // Check if the lock file exists try { await fs.access(lockFilePath); } catch { // Lock file does not exist — nothing to remove return false; } // Remove the stale lock file await fs.unlink(lockFilePath); logger.info('Removed stale index.lock file', { worktreePath, lockFilePath }); return true; } catch (err) { logger.warn('Failed to remove stale index.lock file', { worktreePath, error: err instanceof Error ? err.message : String(err), }); return false; } } /** * Execute a git command with automatic retry when a stale index.lock is detected. * * If the command fails with an error indicating a locked index file, this * helper will attempt to remove the stale `.git/index.lock` and retry the * command exactly once. * * This is particularly useful for `git stash push` which writes to the * index and commonly fails when a previous git operation was interrupted. * * @param args - Array of git command arguments * @param cwd - Working directory to execute the command in * @param env - Optional additional environment variables * @returns Promise resolving to stdout output * @throws The original error if retry also fails, or a non-lock error */ export async function execGitCommandWithLockRetry( args: string[], cwd: string, env?: Record ): Promise { try { return await execGitCommand(args, cwd, env); } catch (error: unknown) { const err = error as { message?: string; stderr?: string }; const errorMessage = err.stderr || err.message || ''; if (!isIndexLockError(errorMessage)) { throw error; } logger.info('Git command failed due to index lock, attempting cleanup and retry', { cwd, args: args.join(' '), }); const removed = await removeStaleIndexLock(cwd); if (!removed) { // Could not remove the lock file — re-throw the original error throw error; } // Retry the command once after removing the lock file return await execGitCommand(args, cwd, env); } }