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>
135 lines
4.5 KiB
TypeScript
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]);
|
|
},
|
|
};
|
|
}
|