Files
fusion/packages/engine/src/experiment/git-ops.ts
Fusion b8c5cf4f72 feat(FN-4222): complete Step 2 — extend git ops for finalize
Fusion-Task-Id: FN-4222
Fusion-Task-Lineage: 7de6b18c-36d5-49e8-bbd7-084707e88d6a
2026-05-14 04:01:49 -07:00

132 lines
4.2 KiB
TypeScript

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<string>;
add(paths: string[]): Promise<void>;
commit(message: string): Promise<string>;
resetHard(ref: string): Promise<void>;
stashPush(message: string): Promise<string | null>;
stashPop(ref: string): Promise<void>;
statusPorcelain(): Promise<string>;
mergeBase(refA: string, refB: string): Promise<string>;
branchExists(name: string): Promise<boolean>;
createBranch(name: string, startPoint: string): Promise<void>;
cherryPick(commit: string): Promise<void>;
checkout(ref: string): Promise<void>;
currentBranch(): Promise<string | null>;
deleteBranch(name: string, opts?: { force?: boolean }): Promise<void>;
}
async function runGit(cwd: string, args: string[]): Promise<string> {
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]);
},
};
}