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>
This commit is contained in:
7
.changeset/fix-fusion-git-identity.md
Normal file
7
.changeset/fix-fusion-git-identity.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fusion now sets its own git identity for commits, attributing them to the agent that did the work.
|
||||
category: fix
|
||||
dev: Merge commits, the merger's `--amend`, and experiment git-ops all relied on ambient `user.name`/`user.email`; only workspace-fence-ref.ts passed an explicit identity. On a host with no git identity — container, CI, fresh machine — git refuses with "Author identity unknown" and an auto-merge stalls at `status:merging` with nothing surfaced. New `resolveCommitIdentity` in packages/engine/src/git-identity.ts resolves operator `commitAuthor*` settings > acting agent (`Name (Fusion) <slug@agents.fusion.local>`) > `Fusion <noreply@runfusion.ai>`, applied via `mergerCommitEnv` (author AND committer) and via `-c` args for the two paths that build their own argv. `commitAuthorEnabled: false` opts out and restores ambient config.
|
||||
67
packages/engine/src/__tests__/git-identity.test.ts
Normal file
67
packages/engine/src/__tests__/git-identity.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
commitIdentityArgs,
|
||||
commitIdentityEnv,
|
||||
FUSION_FALLBACK_IDENTITY,
|
||||
resolveCommitIdentity,
|
||||
} from "../git-identity.js";
|
||||
|
||||
/*
|
||||
FNXC:GitIdentity 2026-08-18-07:55:
|
||||
Fusion-authored commits must carry an identity Fusion chose. Relying on ambient `user.name`/
|
||||
`user.email` meant that on a host with none — container, CI, fresh machine — git refused with
|
||||
"Author identity unknown" and an auto-merge stalled at `status:merging` with nothing surfaced.
|
||||
*/
|
||||
describe("resolveCommitIdentity", () => {
|
||||
it("attributes a commit to the acting agent", () => {
|
||||
const identity = resolveCommitIdentity({ agent: { name: "QA Engineer", id: "agent_1" } });
|
||||
expect(identity).toEqual({ name: "QA Engineer (Fusion)", email: "qa-engineer@agents.fusion.local" });
|
||||
});
|
||||
|
||||
it("falls back to the agent id when it has no name", () => {
|
||||
expect(resolveCommitIdentity({ agent: { id: "agent_42" } })?.email).toBe("agent-42@agents.fusion.local");
|
||||
});
|
||||
|
||||
it("prefers explicit operator settings over the agent", () => {
|
||||
const identity = resolveCommitIdentity({
|
||||
agent: { name: "QA Engineer" },
|
||||
settings: { commitAuthorName: "Release Bot", commitAuthorEmail: "release@example.com" },
|
||||
});
|
||||
expect(identity).toEqual({ name: "Release Bot", email: "release@example.com" });
|
||||
});
|
||||
|
||||
it("still yields an identity when no agent is known", () => {
|
||||
// The important half: never return undefined just because the caller lacks agent context.
|
||||
expect(resolveCommitIdentity()).toEqual(FUSION_FALLBACK_IDENTITY);
|
||||
});
|
||||
|
||||
it("opts out entirely when the operator disables Fusion authorship", () => {
|
||||
// undefined means "leave git alone" — the escape hatch for commits authored as the operator.
|
||||
expect(resolveCommitIdentity({ settings: { commitAuthorEnabled: false }, agent: { name: "QA" } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit an unusable email for a name with no alphanumerics", () => {
|
||||
expect(resolveCommitIdentity({ agent: { name: "***" } })).toEqual(FUSION_FALLBACK_IDENTITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit identity plumbing", () => {
|
||||
it("pins committer as well as author", () => {
|
||||
// Author alone is not enough: git fails on a missing COMMITTER identity just as hard.
|
||||
expect(commitIdentityEnv({ name: "N", email: "e@x" })).toEqual({
|
||||
GIT_AUTHOR_NAME: "N",
|
||||
GIT_AUTHOR_EMAIL: "e@x",
|
||||
GIT_COMMITTER_NAME: "N",
|
||||
GIT_COMMITTER_EMAIL: "e@x",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits nothing when authorship is opted out", () => {
|
||||
expect(commitIdentityEnv(undefined)).toEqual({});
|
||||
expect(commitIdentityArgs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds -c overrides for argv callers", () => {
|
||||
expect(commitIdentityArgs({ name: "N", email: "e@x" })).toEqual(["-c", "user.name=N", "-c", "user.email=e@x"]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { commitIdentityArgs, resolveCommitIdentity } from "../git-identity.js";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
@@ -54,7 +55,9 @@ export function defaultGitOps(cwd: string): GitOps {
|
||||
await runGit(cwd, ["add", ...paths]);
|
||||
},
|
||||
async commit(message: string) {
|
||||
await runGit(cwd, ["commit", "-m", JSON.stringify(message)]);
|
||||
// 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) {
|
||||
|
||||
108
packages/engine/src/git-identity.ts
Normal file
108
packages/engine/src/git-identity.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
FNXC:GitIdentity 2026-08-18-07:55:
|
||||
FUSION SUPPLIES THE GIT IDENTITY FOR ITS OWN COMMITS; IT DOES NOT BORROW THE ENVIRONMENT'S.
|
||||
|
||||
Every Fusion-authored commit (merge commits, the merger's `--amend`, experiment git-ops) used to run
|
||||
with whatever `user.name`/`user.email` happened to be configured on the host. On a machine with no
|
||||
git identity — a container, CI, a fresh laptop — git refuses outright with "Author identity unknown
|
||||
... Please tell me who you are", so an auto-merge reached `status:merging` and stopped dead with
|
||||
nothing in the UI explaining why (operator report). The only place that ever passed an explicit
|
||||
identity was workspace-fence-ref.ts.
|
||||
|
||||
Identity is per-agent where the caller knows which agent did the work, so history attributes a change
|
||||
to the agent that made it rather than to one anonymous bot. The operator's `commitAuthor*` settings
|
||||
still win when set, and setting `commitAuthorEnabled: false` opts out entirely and restores ambient
|
||||
git config — that is the escape hatch for anyone who wants commits authored as themselves.
|
||||
*/
|
||||
|
||||
/** Identity applied to a git commit Fusion creates. */
|
||||
export interface CommitIdentity {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const FUSION_FALLBACK_IDENTITY: CommitIdentity = {
|
||||
name: "Fusion",
|
||||
email: "noreply@runfusion.ai",
|
||||
};
|
||||
|
||||
/** Local-part safe slug for an agent-derived email; empty when nothing usable remains. */
|
||||
export function slugifyAgentEmailLocalPart(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export interface CommitIdentityInput {
|
||||
/** The agent performing the work, when the call site knows it. */
|
||||
agent?: { name?: string | null; id?: string | null } | null;
|
||||
settings?: {
|
||||
commitAuthorEnabled?: boolean;
|
||||
commitAuthorName?: string;
|
||||
commitAuthorEmail?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the identity for a Fusion-authored commit.
|
||||
*
|
||||
* Precedence: explicit operator settings > the acting agent > the Fusion fallback. Returns
|
||||
* `undefined` when the operator disabled Fusion's authorship, which means "leave git alone and use
|
||||
* whatever the environment provides".
|
||||
*/
|
||||
export function resolveCommitIdentity(input: CommitIdentityInput = {}): CommitIdentity | undefined {
|
||||
const { agent, settings } = input;
|
||||
if (settings?.commitAuthorEnabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredName = settings?.commitAuthorName?.trim();
|
||||
const configuredEmail = settings?.commitAuthorEmail?.trim();
|
||||
if (configuredName && configuredEmail) {
|
||||
return { name: configuredName, email: configuredEmail };
|
||||
}
|
||||
|
||||
const agentName = agent?.name?.trim() || agent?.id?.trim() || "";
|
||||
if (agentName) {
|
||||
const localPart = slugifyAgentEmailLocalPart(agentName);
|
||||
if (localPart) {
|
||||
return {
|
||||
name: configuredName || `${agentName} (Fusion)`,
|
||||
email: configuredEmail || `${localPart}@agents.fusion.local`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: configuredName || FUSION_FALLBACK_IDENTITY.name,
|
||||
email: configuredEmail || FUSION_FALLBACK_IDENTITY.email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment overrides that pin author AND committer.
|
||||
*
|
||||
* Both halves matter: setting only the author still leaves the COMMITTER to ambient config, and git
|
||||
* fails on a missing committer identity just as hard as on a missing author.
|
||||
*/
|
||||
export function commitIdentityEnv(identity: CommitIdentity | undefined): NodeJS.ProcessEnv {
|
||||
if (!identity) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
GIT_AUTHOR_NAME: identity.name,
|
||||
GIT_AUTHOR_EMAIL: identity.email,
|
||||
GIT_COMMITTER_NAME: identity.name,
|
||||
GIT_COMMITTER_EMAIL: identity.email,
|
||||
};
|
||||
}
|
||||
|
||||
/** `-c user.name=… -c user.email=…` for callers that build an argv rather than an env. */
|
||||
export function commitIdentityArgs(identity: CommitIdentity | undefined): string[] {
|
||||
if (!identity) {
|
||||
return [];
|
||||
}
|
||||
return ["-c", `user.name=${identity.name}`, "-c", `user.email=${identity.email}`];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { commitIdentityArgs, resolveCommitIdentity } from "../git-identity.js";
|
||||
/**
|
||||
* Standalone AI merge path (FN-5633).
|
||||
*
|
||||
@@ -388,7 +389,13 @@ async function ensureCommitTaskMetadata(
|
||||
const missingTrailers = trailers.filter((t) => !fullMessage.includes(t));
|
||||
if (!needsPrefix && missingTrailers.length === 0) return;
|
||||
|
||||
const args = ["-c", "trailer.ifExists=addIfDifferent", "commit", "--amend"];
|
||||
/*
|
||||
FNXC:GitIdentity 2026-08-18-07:55:
|
||||
This amend does not go through merger.ts's mergerCommitEnv, so it needs the identity applied to its
|
||||
own argv — otherwise it is the one Fusion commit that still depends on ambient git config and fails
|
||||
on a host that has none.
|
||||
*/
|
||||
const args = [...commitIdentityArgs(resolveCommitIdentity()), "-c", "trailer.ifExists=addIfDifferent", "commit", "--amend"];
|
||||
if (needsPrefix) {
|
||||
// Rewrite the message with the task-id-prefixed subject (body, which already
|
||||
// carries any existing trailers, is preserved verbatim).
|
||||
|
||||
@@ -23,9 +23,23 @@ const execFileAsync: (file: string, args: string[], opts?: import("node:child_pr
|
||||
* checks for the exact value "1" so a leaked/empty var cannot accidentally
|
||||
* bypass agent commits.
|
||||
*/
|
||||
function mergerCommitEnv(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, [IDENTITY_GUARD_BYPASS_ENV]: "1" };
|
||||
/*
|
||||
FNXC:GitIdentity 2026-08-18-07:55:
|
||||
Every merge commit runs through this env, which is why the identity is applied here rather than at
|
||||
eight separate call sites. Without it these commits inherited whatever git identity the host
|
||||
happened to have — and on a host with none (container, CI, fresh machine) git refuses to commit at
|
||||
all, so the merge stalled at `status:merging` with no error surfaced. `resolveCommitIdentity`
|
||||
returns undefined only when the operator sets `commitAuthorEnabled: false`, which restores the old
|
||||
ambient-config behaviour for anyone who wants commits authored as themselves.
|
||||
*/
|
||||
function mergerCommitEnv(identity?: CommitIdentity): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
[IDENTITY_GUARD_BYPASS_ENV]: "1",
|
||||
...commitIdentityEnv(identity ?? resolveCommitIdentity()),
|
||||
};
|
||||
}
|
||||
import { commitIdentityEnv, resolveCommitIdentity, type CommitIdentity } from "./git-identity.js";
|
||||
import {
|
||||
detectMissingWorkspaceEntry,
|
||||
runVerificationCommand as runVerificationCommandShared,
|
||||
|
||||
Reference in New Issue
Block a user