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 13789d6c88
commit a73cfc710b
3 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import type {
ExperimentRunRecordPayload,
ExperimentSession,
ExperimentSessionRecord,
} from "@fusion/core";
import type { GitOps } from "../experiment/git-ops.js";
import {
commitKept,
ExperimentRevertConflictError,
revertDiscarded,
} from "../experiment/git-policy.js";
const baseSession: ExperimentSession = {
id: "EXP-001",
projectId: "proj",
name: "session",
metric: { name: "accuracy", direction: "maximize" },
status: "active",
currentSegment: 1,
maxIterations: 10,
tags: [],
bestRunId: null,
baselineCommit: null,
keptRunIds: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const baseRunRecord: ExperimentSessionRecord = {
id: "EXPR-001",
sessionId: "EXP-001",
segment: 1,
type: "run",
payload: { status: "keep", secondaryMetrics: [] },
createdAt: new Date().toISOString(),
};
const baseRunPayload: ExperimentRunRecordPayload = {
status: "keep",
primaryMetric: 0.91,
secondaryMetrics: [],
};
function createGitMock(): GitOps {
return {
head: vi.fn(),
add: vi.fn(),
commit: vi.fn(),
resetHard: vi.fn(),
stashPush: vi.fn(),
stashPop: vi.fn(),
statusPorcelain: vi.fn(),
};
}
describe("git policy", () => {
it("commitKept stages and commits with default message", async () => {
const git = createGitMock();
vi.mocked(git.commit).mockResolvedValue("abc123");
const result = await commitKept({
session: baseSession,
runRecord: baseRunRecord,
runPayload: baseRunPayload,
git,
});
expect(git.add).toHaveBeenCalledWith(["-A"]);
expect(git.commit).toHaveBeenCalledWith(
"experiment(EXP-001): keep EXPR-001 — accuracy=0.91",
);
expect(result).toEqual({ commit: "abc123" });
});
it("revertDiscarded without preserved paths only resets", async () => {
const git = createGitMock();
vi.mocked(git.statusPorcelain).mockResolvedValue(" M src/file.ts");
const result = await revertDiscarded({
session: baseSession,
git,
baselineCommit: "base-sha",
});
expect(git.stashPush).not.toHaveBeenCalled();
expect(git.resetHard).toHaveBeenCalledWith("base-sha");
expect(result).toEqual({ revertedTo: "base-sha", preservedPaths: [] });
});
it("revertDiscarded with preserved path stashes then pops", async () => {
const git = createGitMock();
vi.mocked(git.statusPorcelain).mockResolvedValue(
" M autoresearch.jsonl\n M src/file.ts",
);
vi.mocked(git.stashPush).mockResolvedValue("stash@{0}");
await revertDiscarded({
session: baseSession,
git,
baselineCommit: "base-sha",
});
expect(git.add).toHaveBeenCalledWith(["autoresearch.jsonl"]);
expect(git.stashPush).toHaveBeenCalledOnce();
expect(git.resetHard).toHaveBeenCalledWith("base-sha");
expect(git.stashPop).toHaveBeenCalledWith("stash@{0}");
});
it("rethrow stash pop conflicts as ExperimentRevertConflictError", async () => {
const git = createGitMock();
vi.mocked(git.statusPorcelain).mockResolvedValue(" M autoresearch.md");
vi.mocked(git.stashPush).mockResolvedValue("stash@{1}");
vi.mocked(git.stashPop).mockRejectedValue(new Error("conflict"));
await expect(
revertDiscarded({
session: baseSession,
git,
baselineCommit: "base-sha",
}),
).rejects.toBeInstanceOf(ExperimentRevertConflictError);
});
it("does not preserve similarly-named non-matching files", async () => {
const git = createGitMock();
vi.mocked(git.statusPorcelain).mockResolvedValue(" M autoresearch.jsonl.bak");
const result = await revertDiscarded({
session: baseSession,
git,
baselineCommit: "base-sha",
});
expect(git.add).not.toHaveBeenCalled();
expect(result.preservedPaths).toEqual([]);
});
});

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 };
}