import { exec } from "node:child_process"; import { promisify } from "node:util"; import { ExperimentFinalizeBranchExistsError, ExperimentFinalizeCherryPickConflictError, ExperimentFinalizeMergeBaseError, } from "./finalize-types.js"; const execAsync = promisify(exec); const GIT_TIMEOUT_MS = 30_000; const GIT_MAX_BUFFER = 10 * 1024 * 1024; export interface GitOps { head(): Promise; add(paths: string[]): Promise; commit(message: string): Promise; resetHard(ref: string): Promise; stashPush(message: string): Promise; stashPop(ref: string): Promise; statusPorcelain(): Promise; mergeBase(refA: string, refB: string): Promise; branchExists(name: string): Promise; createBranch(name: string, startPoint: string): Promise; cherryPick(commit: string): Promise; checkout(ref: string): Promise; currentBranch(): Promise; deleteBranch(name: string, opts?: { force?: boolean }): Promise; } async function runGit(cwd: string, args: string[]): Promise { const command = `git ${args.join(" ")}`; try { const { stdout } = await execAsync(command, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, }); return stdout.trim(); } catch (error) { const err = error as Error & { stderr?: string; stdout?: string }; const stderr = err.stderr?.trim(); const stdout = err.stdout?.trim(); const detail = stderr || stdout || err.message; throw new Error(`Git command failed (${command}): ${detail}`); } } export function defaultGitOps(cwd: string): GitOps { return { async head() { return await runGit(cwd, ["rev-parse", "HEAD"]); }, async add(paths: string[]) { await runGit(cwd, ["add", ...paths]); }, async commit(message: string) { await runGit(cwd, ["commit", "-m", JSON.stringify(message)]); return await runGit(cwd, ["rev-parse", "HEAD"]); }, async resetHard(ref: string) { await runGit(cwd, ["reset", "--hard", ref]); }, async stashPush(message: string) { const output = await runGit(cwd, ["stash", "push", "-m", JSON.stringify(message)]); if (output.includes("No local changes to save")) { return null; } const match = output.match(/(stash@\{\d+\})/); return match?.[1] ?? "stash@{0}"; }, async stashPop(ref: string) { await runGit(cwd, ["stash", "pop", ref]); }, async statusPorcelain() { return await runGit(cwd, ["status", "--porcelain"]); }, async mergeBase(refA: string, refB: string) { try { return await runGit(cwd, ["merge-base", refA, refB]); } catch (error) { const err = error as Error; throw new ExperimentFinalizeMergeBaseError(`Unable to resolve merge-base for ${refA} and ${refB}: ${err.message}`); } }, async branchExists(name: string) { try { await runGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]); return true; } catch { return false; } }, async createBranch(name: string, startPoint: string) { const exists = await this.branchExists(name); if (exists) { throw new ExperimentFinalizeBranchExistsError(`Branch already exists: ${name}`); } await runGit(cwd, ["branch", name, startPoint]); }, async cherryPick(commit: string) { try { await runGit(cwd, ["cherry-pick", commit]); } catch (error) { const err = error as Error; try { await runGit(cwd, ["cherry-pick", "--abort"]); } catch { // best effort } throw new ExperimentFinalizeCherryPickConflictError(`Cherry-pick failed for ${commit}`, { groupId: "unknown", commit, stderr: err.message, }); } }, async checkout(ref: string) { await runGit(cwd, ["checkout", ref]); }, async currentBranch() { try { return await runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); } catch { return null; } }, async deleteBranch(name: string, opts?: { force?: boolean }) { await runGit(cwd, ["branch", opts?.force ? "-D" : "-d", name]); }, }; }