/* Shared git command executor for werkstatt and bordbuch modules. Extracted from bordbuch-io.ts (RFC-0580) to avoid cross-module dependency. Provides gitExec (synchronous) and gitExecWithRetry (async with transient-failure retry, RFC-0646). Does not define git workflow logic — that lives in callers. Does not retry non-transient errors — only timeout and lock-file errors are retried (RFC-0646). RFC-0580: extract gitExec from bordbuch-io.ts into shared utility with allowNonZero option. RFC-0646: add gitExecWithRetry companion with RetryOptions for transient-failure resilience. RFC-0794: add optional env parameter to gitExec and gitExecWithRetry for passing environment variables (e.g. ECOSYSTEM_COMMIT=1) to git commands. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared */ import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export async function gitExec( cwd: string, args: string, options?: { allowNonZero?: boolean; env?: NodeJS.ProcessEnv }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const env = options?.env ? (Object.fromEntries(Object.entries(options.env).filter(([, v]) => v !== undefined)) as Record< string, string >) : undefined; const result = await io.exec("sh", ["-c", `git ${args}`], { cwd, timeoutMs: 30_000, env, }); if (result.exitCode !== 0) { if (options?.allowNonZero) { return ""; } throw new Error(`git ${args} failed (${result.exitCode}): ${result.stderr}`); } return result.stdout.trim(); } export interface RetryOptions { backoffMs: number[]; } function isTransientError(err: unknown): boolean { if (err === null || typeof err !== "object") return false; const e = err as Record; const message = typeof e.message === "string" ? e.message : ""; const code = typeof e.code === "string" ? e.code : ""; const status = typeof e.status === "number" ? e.status : null; if (code === "ETIMEDOUT") return true; if (message.includes("index.lock")) return true; if (message.includes("Another git process seems to be running")) return true; if (status === 128 && message.includes("lock")) return true; if (message.includes("timeout") || message.includes("timed out")) return true; return false; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function gitExecWithRetry( cwd: string, args: string, retryOptions: RetryOptions, options?: { allowNonZero?: boolean; env?: NodeJS.ProcessEnv }, io: WorkspaceIO = createDefaultIO().io, ): Promise { let lastError: unknown; for (let attempt = 0; attempt <= retryOptions.backoffMs.length; attempt++) { try { return await gitExec(cwd, args, options, io); } catch (err) { lastError = err; if (!isTransientError(err)) throw err; if (attempt < retryOptions.backoffMs.length) { await sleep(retryOptions.backoffMs[attempt]); } } } throw lastError; }