diff --git a/.changeset/fix-fusion-git-identity.md b/.changeset/fix-fusion-git-identity.md new file mode 100644 index 0000000000..382f5de6d6 --- /dev/null +++ b/.changeset/fix-fusion-git-identity.md @@ -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) `) > `Fusion `, 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. diff --git a/packages/engine/src/__tests__/git-identity.test.ts b/packages/engine/src/__tests__/git-identity.test.ts new file mode 100644 index 0000000000..77a91d8c18 --- /dev/null +++ b/packages/engine/src/__tests__/git-identity.test.ts @@ -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"]); + }); +}); diff --git a/packages/engine/src/experiment/git-ops.ts b/packages/engine/src/experiment/git-ops.ts index 1556c79fe3..cc1eeddf32 100644 --- a/packages/engine/src/experiment/git-ops.ts +++ b/packages/engine/src/experiment/git-ops.ts @@ -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) { diff --git a/packages/engine/src/git-identity.ts b/packages/engine/src/git-identity.ts new file mode 100644 index 0000000000..1bf09e8415 --- /dev/null +++ b/packages/engine/src/git-identity.ts @@ -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}`]; +} diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index a4f62a33a0..2d585feaff 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -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). diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index ac13b0da5a..5e5ae6d668 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -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,