Files
fusion/packages/engine/src/experiment/git-ops.ts
gsxdsm 1da6375c68 fix(git): Fusion sets its own commit identity, per agent
Auto-merge did nothing on a fresh container: tasks reached in-review, the merge
began, and FN-001 sat at status:merging forever with no error in the UI. The
cause was that git could not commit at all — `git commit` in the container dies
with "Author identity unknown ... Please tell me who you are", because a
container has no git identity and Fusion was borrowing the environment's.

Only workspace-fence-ref.ts ever passed an explicit identity. The merge commits,
the merger's --amend, and the experiment git-ops all inherited whatever
user.name/user.email the host happened to have. The existing commitAuthor*
settings only added a Co-authored-by TRAILER; they never set the author.

resolveCommitIdentity (packages/engine/src/git-identity.ts) now resolves:
operator commitAuthor* settings > the acting agent > Fusion. An agent-derived
identity is `<Agent Name> (Fusion) <slug@agents.fusion.local>`, so history
attributes a change to the agent that made it instead of one anonymous bot, per
the operator's request.

Applied at mergerCommitEnv — the single env all eight merger commit sites share
— and via `-c user.name/-c user.email` for merger-ai and experiment git-ops,
which build their own argv and bypass that env. Author AND committer are pinned:
git fails on a missing committer just as hard as on a missing author.

`commitAuthorEnabled: false` returns undefined and restores ambient git config,
so an operator who wants commits authored as themselves keeps that.

Verified: 9 new helper tests, 95 engine auth+identity tests, pnpm test:gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:49:33 -07:00

135 lines
4.5 KiB
TypeScript

import { commitIdentityArgs, resolveCommitIdentity } from "../git-identity.js";
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) {
// FNXC:GitIdentity 2026-08-18-07:55: explicit identity — experiment commits must not depend on
// ambient git config either (a host without one cannot commit at all).
await runGit(cwd, [...commitIdentityArgs(resolveCommitIdentity()), "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]);
},
};
}