feat(FN-4219): complete Step 3 — add git policy and ops

Fusion-Task-Id: FN-4219
Fusion-Task-Lineage: 8d9a9ba6-6729-4376-b935-549e28a7fa35
This commit is contained in:
Fusion
2026-05-14 00:28:33 -07:00
committed by gsxdsm
parent f86403682b
commit 77fe4fa277
3 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
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>;
}
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"]);
},
};
}

View File

@@ -0,0 +1,83 @@
import type {
ExperimentRunRecordPayload,
ExperimentSession,
ExperimentSessionRecord,
} from "@fusion/core";
import type { GitOps } from "./git-ops.js";
export const AUTORESEARCH_PRESERVED_PATHS = [
"autoresearch.jsonl",
"autoresearch.md",
"autoresearch.ideas.md",
"autoresearch.checks.sh",
"autoresearch.config.json",
"autoresearch.hooks/",
] as const;
export function isPreservedAutoresearchPath(pathname: string): boolean {
return AUTORESEARCH_PRESERVED_PATHS.some((preserved) =>
preserved.endsWith("/")
? pathname === preserved.slice(0, -1) || pathname.startsWith(preserved)
: pathname === preserved,
);
}
export class ExperimentRevertConflictError extends Error {
constructor(message: string, public readonly causeError?: unknown) {
super(message);
this.name = "ExperimentRevertConflictError";
}
}
export async function commitKept(opts: {
session: ExperimentSession;
runRecord: ExperimentSessionRecord;
runPayload: ExperimentRunRecordPayload;
git: GitOps;
commitMessage?: string;
}): Promise<{ commit: string }> {
const metricName = opts.session.metric.name;
const metricValue = opts.runPayload.primaryMetric ?? "n/a";
const message =
opts.commitMessage ??
`experiment(${opts.session.id}): keep ${opts.runRecord.id}${metricName}=${metricValue}`;
await opts.git.add(["-A"]);
const commit = await opts.git.commit(message);
return { commit };
}
export async function revertDiscarded(opts: {
session: ExperimentSession;
git: GitOps;
baselineCommit: string;
}): Promise<{ revertedTo: string; preservedPaths: string[] }> {
const status = await opts.git.statusPorcelain();
const preservedPaths = status
.split(/\r?\n/)
.map((line) => line.match(/^..\s+(.+)$/)?.[1]?.trim() ?? "")
.filter(Boolean)
.filter((pathname) => isPreservedAutoresearchPath(pathname));
let stashRef: string | null = null;
if (preservedPaths.length > 0) {
await opts.git.add(preservedPaths);
stashRef = await opts.git.stashPush(`experiment-preserve-${opts.session.id}`);
}
await opts.git.resetHard(opts.baselineCommit);
if (stashRef) {
try {
await opts.git.stashPop(stashRef);
} catch (error) {
throw new ExperimentRevertConflictError(
`Failed to restore preserved autoresearch artifacts for ${opts.session.id}`,
error,
);
}
}
return { revertedTo: opts.baselineCommit, preservedPaths };
}