FN-7065: add deterministic Fusion co-author trailers
Fusion now applies co-author attribution through merge and worktree plumbing instead of relying on prompt-written commit messages. - Add commit-msg hook support for deterministic Co-authored-by trailers with configured Fusion identity. - Propagate commit attribution settings through native and worktrunk worktree backends. - Backfill AI squash merge trailers with Fusion task metadata and co-author attribution. - Document the settings behavior and add a patch changeset for published CLI behavior. - Cover hook, executor, AI merge, and real-git trailer behavior with tests. Files changed: .changeset/fn-7065-co-author-trailer.md | 7 +++ docs/settings-reference.md | 6 +-- packages/engine/src/__tests__/merger-ai.test.ts | 43 +++++++++++++++++++ .../real-git/commit-msg-trailer.real-git.test.ts | 50 ++++++++++++++++++++++ .../engine/src/__tests__/worktree-hooks.test.ts | 15 +++++++ packages/engine/src/executor.ts | 4 ++ packages/engine/src/merger-ai.ts | 22 ++++++++-- packages/engine/src/step-session-executor.ts | 3 ++ packages/engine/src/worktree-acquisition.ts | 3 ++ packages/engine/src/worktree-backend.ts | 13 +++++- packages/engine/src/worktree-hooks.ts | 33 +++++++++++++- 11 files changed, 190 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7065 Fusion-Task-Lineage: 4f5cd0ff-7193-4f97-bdcd-4cd78c7669b9
This commit is contained in:
7
.changeset/fn-7065-co-author-trailer.md
Normal file
7
.changeset/fn-7065-co-author-trailer.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fusion co-author attribution now lands reliably on every commit it makes.
|
||||
category: fix
|
||||
dev: Inject the `Co-authored-by` trailer deterministically via the worktree commit-msg hook and the merger-ai `ensureCommitTaskMetadata` backfill (gated by `commitAuthorEnabled`), instead of relying on the agent appending it from the prompt.
|
||||
@@ -467,9 +467,9 @@ Default notes:
|
||||
|
||||
| `taskPrefix` | `string` | `"FN"` | Prefix used for newly generated task IDs. |
|
||||
| `includeTaskIdInCommit` | `boolean` | `true` | Include task ID as commit scope in generated commits. |
|
||||
| `commitAuthorEnabled` | `boolean` | `true` | Apply explicit `--author` attribution on Fusion commits. |
|
||||
| `commitAuthorName` | `string` | `"Fusion"` | Commit author name when `commitAuthorEnabled` is true. |
|
||||
| `commitAuthorEmail` | `string` | `"noreply@runfusion.ai"` | Commit author email when `commitAuthorEnabled` is true. |
|
||||
| `commitAuthorEnabled` | `boolean` | `true` | Add deterministic `Co-authored-by` attribution on Fusion commits. |
|
||||
| `commitAuthorName` | `string` | `"Fusion"` | Co-author trailer name when `commitAuthorEnabled` is true. |
|
||||
| `commitAuthorEmail` | `string` | `"noreply@runfusion.ai"` | Co-author trailer email when `commitAuthorEnabled` is true. |
|
||||
| `planningProvider` | `string` | `undefined` | Provider for planning agents. |
|
||||
| `planningModelId` | `string` | `undefined` | Model ID for planning agents. |
|
||||
| `planningFallbackProvider` | `string` | `undefined` | Fallback provider for planning. |
|
||||
|
||||
@@ -246,6 +246,7 @@ describe("runAiMerge", () => {
|
||||
// "squash: feature" without either — ensureCommitTaskMetadata adds both.
|
||||
const landedMsg = git(dir, "log -1 --pretty=%B main");
|
||||
expect(landedMsg).toContain("Fusion-Task-Id: FN-1");
|
||||
expect((landedMsg.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
expect(git(dir, "log -1 --pretty=%s main")).toMatch(/^FN-1: /);
|
||||
// Task marked merge-backed before moving to done, then event emitted.
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
@@ -259,6 +260,48 @@ describe("runAiMerge", () => {
|
||||
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
|
||||
});
|
||||
|
||||
it("backfills custom AI-merge co-author trailer and respects commitAuthorEnabled false", async () => {
|
||||
const customRepo = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const custom = makeStore(customRepo.dir, {}, { commitAuthorName: "Fusion Bot", commitAuthorEmail: "bot@example.com" });
|
||||
|
||||
await runAiMerge(custom.store, customRepo.dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: realMergeAgent("fusion/fn-1"),
|
||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||
});
|
||||
|
||||
const customMsg = git(customRepo.dir, "log -1 --pretty=%B main");
|
||||
expect((customMsg.match(/Co-authored-by:\s*Fusion Bot <bot@example\.com>/g) ?? []).length).toBe(1);
|
||||
|
||||
const disabledRepo = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const disabled = makeStore(disabledRepo.dir, {}, { commitAuthorEnabled: false });
|
||||
|
||||
await runAiMerge(disabled.store, disabledRepo.dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: realMergeAgent("fusion/fn-1"),
|
||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||
});
|
||||
|
||||
const disabledMsg = git(disabledRepo.dir, "log -1 --pretty=%B main");
|
||||
expect(disabledMsg).toContain("Fusion-Task-Id: FN-1");
|
||||
expect(disabledMsg).not.toContain("Co-authored-by:");
|
||||
});
|
||||
|
||||
it("does not duplicate an identical AI-merge co-author trailer from the agent", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const { store } = makeStore(dir);
|
||||
|
||||
await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: vi.fn(async (cwd: string) => {
|
||||
execSync("git merge --squash fusion/fn-1", { cwd, stdio: "pipe" });
|
||||
execSync("git add -A", { cwd, stdio: "pipe" });
|
||||
execSync('git commit -q -m "squash: feature" -m "Co-authored-by: Fusion <noreply@runfusion.ai>"', { cwd, stdio: "pipe" });
|
||||
}),
|
||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||
});
|
||||
|
||||
const msg = git(dir, "log -1 --pretty=%B main");
|
||||
expect((msg.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("persists AI merge agent text/thinking/tool output to agent logs", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const { store } = makeStore(dir, {}, { persistAgentToolOutput: true, persistAgentThinkingLog: true });
|
||||
|
||||
@@ -37,17 +37,26 @@ describe("commit-msg trailer hook (real git)", () => {
|
||||
git(worktreeDir, "git add step1.txt && git commit -m 'feat(KB-7): first'");
|
||||
const firstBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(firstBody).toContain("Fusion-Task-Id: KB-7");
|
||||
expect((firstBody.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
const firstTrailers = git(worktreeDir, "git log -1 --format=%B | git interpret-trailers --parse");
|
||||
expect(firstTrailers).toContain("Fusion-Task-Id: KB-7");
|
||||
expect(firstTrailers).toContain("Co-authored-by: Fusion <noreply@runfusion.ai>");
|
||||
|
||||
git(worktreeDir, "git commit --amend --no-edit");
|
||||
const amendNoEditBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect((amendNoEditBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
|
||||
expect((amendNoEditBody.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
|
||||
git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten'");
|
||||
const rewrittenBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(rewrittenBody).toContain("feat(KB-7): rewritten");
|
||||
expect((rewrittenBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
|
||||
expect((rewrittenBody.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
|
||||
writeFileSync(join(worktreeDir, "step-manual.txt"), "manual\n");
|
||||
git(worktreeDir, "git add step-manual.txt && git commit -m 'feat(KB-7): manual coauthor' -m 'Co-authored-by: Fusion <noreply@runfusion.ai>'");
|
||||
const manualBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect((manualBody.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
|
||||
const taskFile = git(worktreeDir, "git rev-parse --git-path fusion-task-id");
|
||||
writeFileSync(isAbsolute(taskFile) ? taskFile : resolve(worktreeDir, taskFile), "kb-7\n");
|
||||
@@ -55,6 +64,7 @@ describe("commit-msg trailer hook (real git)", () => {
|
||||
git(worktreeDir, "git add step2.txt && git commit -m 'feat(KB-7): lowercase metadata'");
|
||||
const lowercaseBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(lowercaseBody).toContain("Fusion-Task-Id: KB-7");
|
||||
expect((lowercaseBody.match(/Co-authored-by:\s*Fusion <noreply@runfusion\.ai>/g) ?? []).length).toBe(1);
|
||||
|
||||
writeFileSync(join(rootDir, "outside.txt"), "outside\n");
|
||||
git(rootDir, "git add outside.txt && git commit -m 'chore: root commit'");
|
||||
@@ -64,4 +74,44 @@ describe("commit-msg trailer hook (real git)", () => {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("uses custom co-author settings and honors commitAuthorEnabled false", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-7065-commit-author-"));
|
||||
const customWorktreeDir = join(rootDir, "wt-custom");
|
||||
const disabledWorktreeDir = join(rootDir, "wt-disabled");
|
||||
|
||||
try {
|
||||
git(rootDir, "git init -b main");
|
||||
git(rootDir, 'git config user.email "test@example.com"');
|
||||
git(rootDir, 'git config user.name "Test"');
|
||||
writeFileSync(join(rootDir, "README.md"), "init\n");
|
||||
git(rootDir, "git add README.md && git commit -m 'init'");
|
||||
|
||||
git(rootDir, "git worktree add -b fusion/fn-7065-custom wt-custom HEAD");
|
||||
await installTaskWorktreeIdentityGuard({
|
||||
worktreePath: customWorktreeDir,
|
||||
taskId: "FN-7065-CUSTOM",
|
||||
commitAuthorName: "Fusion Bot",
|
||||
commitAuthorEmail: "bot@example.com",
|
||||
});
|
||||
writeFileSync(join(customWorktreeDir, "custom.txt"), "custom\n");
|
||||
git(customWorktreeDir, "git add custom.txt && git commit -m 'feat(FN-7065-CUSTOM): custom author'");
|
||||
const customBody = git(customWorktreeDir, "git log -1 --format=%B");
|
||||
expect((customBody.match(/Co-authored-by:\s*Fusion Bot <bot@example\.com>/g) ?? []).length).toBe(1);
|
||||
|
||||
git(rootDir, "git worktree add -b fusion/fn-7065-disabled wt-disabled HEAD");
|
||||
await installTaskWorktreeIdentityGuard({
|
||||
worktreePath: disabledWorktreeDir,
|
||||
taskId: "FN-7065-DISABLED",
|
||||
commitAuthorEnabled: false,
|
||||
});
|
||||
writeFileSync(join(disabledWorktreeDir, "disabled.txt"), "disabled\n");
|
||||
git(disabledWorktreeDir, "git add disabled.txt && git commit -m 'feat(FN-7065-DISABLED): disabled author'");
|
||||
const disabledBody = git(disabledWorktreeDir, "git log -1 --format=%B");
|
||||
expect(disabledBody).toContain("Fusion-Task-Id: FN-7065-DISABLED");
|
||||
expect(disabledBody).not.toContain("Co-authored-by:");
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -59,9 +59,24 @@ describe("worktree-hooks", () => {
|
||||
expect(hook).toContain("--in-place");
|
||||
expect(hook).toContain("--if-exists doNothing");
|
||||
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
|
||||
expect(hook).toContain("--if-exists addIfDifferent");
|
||||
expect(hook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion <noreply@runfusion.ai>"');
|
||||
expect(hook).toContain("s/^FN-//i");
|
||||
});
|
||||
|
||||
it("parameterizes commit-msg co-author trailer and omits it when disabled", () => {
|
||||
const customHook = buildCommitMsgTrailerHook("FN-42", {
|
||||
commitAuthorName: "Fusion Bot",
|
||||
commitAuthorEmail: "bot@example.com",
|
||||
});
|
||||
expect(customHook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion Bot <bot@example.com>"');
|
||||
expect(customHook).toContain("--if-exists addIfDifferent");
|
||||
|
||||
const disabledHook = buildCommitMsgTrailerHook("FN-42", { commitAuthorEnabled: false });
|
||||
expect(disabledHook).not.toContain("Co-authored-by:");
|
||||
expect(disabledHook).not.toContain("addIfDifferent");
|
||||
});
|
||||
|
||||
it("parameterizes commit-msg hook for custom prefix and trailer name", () => {
|
||||
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
|
||||
expect(hook).toContain('PREFIX="KB"');
|
||||
|
||||
@@ -13919,6 +13919,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
commitMsgHookEnabled: settings.commitMsgHookEnabled,
|
||||
taskPrefix: settings.taskPrefix,
|
||||
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
|
||||
commitAuthorEnabled: settings.commitAuthorEnabled,
|
||||
commitAuthorName: settings.commitAuthorName,
|
||||
commitAuthorEmail: settings.commitAuthorEmail,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -15698,6 +15701,7 @@ export function buildExecutionPrompt(
|
||||
// Build co-author trailer arg for git commits based on settings. The user's
|
||||
// configured git identity remains the primary author; Fusion is appended as
|
||||
// a `Co-authored-by` trailer for shared credit (recognized by GitHub).
|
||||
// FNXC:CommitAttribution 2026-06-26-12:48: this prompt hint is best-effort for humans/agents reading commit examples; the worktree commit-msg hook is the authoritative deterministic source for the co-author trailer.
|
||||
const authorArg = settings?.commitAuthorEnabled !== false
|
||||
? ` -m "Co-authored-by: ${settings?.commitAuthorName || "Fusion"} <${settings?.commitAuthorEmail || "noreply@runfusion.ai"}>"`
|
||||
: "";
|
||||
|
||||
@@ -68,6 +68,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
|
||||
import { DEFAULT_COMMIT_AUTHOR_EMAIL, DEFAULT_COMMIT_AUTHOR_NAME } from "./worktree-hooks.js";
|
||||
import { installWorktreeDependencies } from "./merge-dependency-sync.js";
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
@@ -136,9 +137,22 @@ export {
|
||||
/** Trailers that associate the squash commit with its board task: the
|
||||
* `Fusion-Task-Id` trailer plus the canonical lineage trailer when available.
|
||||
* These are what the board's commit→task association parses. */
|
||||
function taskTrailers(taskId: string, lineageId?: string | null): string[] {
|
||||
function taskTrailers(
|
||||
taskId: string,
|
||||
lineageId?: string | null,
|
||||
settings?: Pick<Settings, "commitAuthorEnabled" | "commitAuthorName" | "commitAuthorEmail">,
|
||||
): string[] {
|
||||
const trailers = [`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`];
|
||||
if (lineageId) trailers.push(buildTaskLineageTrailer(lineageId));
|
||||
if (settings?.commitAuthorEnabled !== false) {
|
||||
const name = (settings?.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
|
||||
const email = (settings?.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
|
||||
/*
|
||||
FNXC:CommitAttribution 2026-06-26-13:02:
|
||||
AI-merge squash commits must receive the same deterministic co-author trailer as executor commits. The backfill amends only missing/different trailers, so an agent-supplied identical Co-authored-by line is not duplicated.
|
||||
*/
|
||||
trailers.push(`Co-authored-by: ${name} <${email}>`);
|
||||
}
|
||||
return trailers;
|
||||
}
|
||||
|
||||
@@ -841,8 +855,8 @@ export async function runAiMerge(
|
||||
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
|
||||
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
// Trailers that link the squash commit to the board task (FN-id + lineage).
|
||||
const trailers = taskTrailers(taskId, task.lineageId);
|
||||
// Trailers that link the squash commit to the board task (FN-id + lineage) and deterministic co-author attribution.
|
||||
const trailers = taskTrailers(taskId, task.lineageId, settings);
|
||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||
|
||||
await setStatus("merging");
|
||||
@@ -1093,7 +1107,7 @@ export async function landWorkspaceTask(
|
||||
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
|
||||
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
const trailers = taskTrailers(taskId, task.lineageId);
|
||||
const trailers = taskTrailers(taskId, task.lineageId, settings);
|
||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||
|
||||
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
||||
|
||||
@@ -1485,6 +1485,9 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
commitMsgHookEnabled: settings.commitMsgHookEnabled,
|
||||
taskPrefix: settings.taskPrefix,
|
||||
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
|
||||
commitAuthorEnabled: settings.commitAuthorEnabled,
|
||||
commitAuthorName: settings.commitAuthorName,
|
||||
commitAuthorEmail: settings.commitAuthorEmail,
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
|
||||
@@ -894,6 +894,9 @@ export async function acquireWorkspaceRepoWorktree(
|
||||
commitMsgHookEnabled: settings.commitMsgHookEnabled,
|
||||
taskPrefix: settings.taskPrefix,
|
||||
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
|
||||
commitAuthorEnabled: settings.commitAuthorEnabled,
|
||||
commitAuthorName: settings.commitAuthorName,
|
||||
commitAuthorEmail: settings.commitAuthorEmail,
|
||||
});
|
||||
} catch (guardErr) {
|
||||
// FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it.
|
||||
|
||||
@@ -339,7 +339,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
||||
settings?: Partial<Pick<Settings, "worktreesDir" | "commitMsgHookEnabled" | "taskPrefix" | "taskAttributionTrailerNames">>;
|
||||
settings?: Partial<Pick<Settings, "worktreesDir" | "commitMsgHookEnabled" | "taskPrefix" | "taskAttributionTrailerNames" | "commitAuthorEnabled" | "commitAuthorName" | "commitAuthorEmail">>;
|
||||
audit?: Pick<RunAuditor, "git">;
|
||||
} = {},
|
||||
) {}
|
||||
@@ -354,6 +354,9 @@ export class NativeWorktreeBackend implements WorktreeBackend {
|
||||
commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled,
|
||||
taskPrefix: this.deps.settings?.taskPrefix,
|
||||
taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0],
|
||||
commitAuthorEnabled: this.deps.settings?.commitAuthorEnabled,
|
||||
commitAuthorName: this.deps.settings?.commitAuthorName,
|
||||
commitAuthorEmail: this.deps.settings?.commitAuthorEmail,
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -637,6 +640,7 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
binaryPath: string | (() => Promise<string | null>) | null;
|
||||
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
||||
audit?: Pick<RunAuditor, "git">;
|
||||
settings?: Partial<Pick<Settings, "commitMsgHookEnabled" | "taskPrefix" | "taskAttributionTrailerNames" | "commitAuthorEnabled" | "commitAuthorName" | "commitAuthorEmail">>;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -744,6 +748,12 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
await installTaskWorktreeIdentityGuard({
|
||||
worktreePath: resolvedPath,
|
||||
taskId: input.taskId,
|
||||
commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled,
|
||||
taskPrefix: this.deps.settings?.taskPrefix,
|
||||
taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0],
|
||||
commitAuthorEnabled: this.deps.settings?.commitAuthorEnabled,
|
||||
commitAuthorName: this.deps.settings?.commitAuthorName,
|
||||
commitAuthorEmail: this.deps.settings?.commitAuthorEmail,
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(resolvedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -1090,6 +1100,7 @@ export function resolveWorktreeBackend(
|
||||
return new WorktrunkWorktreeBackend({
|
||||
binaryPath,
|
||||
logger: deps.logger,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"]
|
||||
export const IDENTITY_GUARD_BYPASS_ENV = "FUSION_MERGER_BYPASS_IDENTITY_GUARD";
|
||||
const COMMIT_MSG_HOOK_MARKER = "# fusion-managed-commit-msg-hook";
|
||||
const PREPARE_COMMIT_MSG_HOOK_MARKER = "# fusion-managed-prepare-commit-msg-hook";
|
||||
export const DEFAULT_COMMIT_AUTHOR_NAME = "Fusion";
|
||||
export const DEFAULT_COMMIT_AUTHOR_EMAIL = "noreply@runfusion.ai";
|
||||
|
||||
function toShellCasePattern(pattern: string): string {
|
||||
return pattern
|
||||
@@ -200,10 +202,27 @@ export function buildCommitMsgTrailerHook(
|
||||
options: {
|
||||
taskPrefix?: string;
|
||||
trailerName?: string;
|
||||
commitAuthorEnabled?: boolean;
|
||||
commitAuthorName?: string;
|
||||
commitAuthorEmail?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const taskPrefix = (options.taskPrefix ?? "FN").trim() || "FN";
|
||||
const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
|
||||
const commitAuthorName = (options.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
|
||||
const commitAuthorEmail = (options.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
|
||||
const coAuthorInjection = options.commitAuthorEnabled === false
|
||||
? ""
|
||||
: `
|
||||
|
||||
# FNXC:CommitAttribution 2026-06-26-12:40:
|
||||
# Co-author attribution must be deterministic in the worktree hook, not dependent on an AI agent remembering a prompt-supplied git commit -m flag. addIfDifferent keeps an identical agent-added trailer from duplicating while preserving distinct human-provided co-authors.
|
||||
CO_AUTHOR_TRAILER=${JSON.stringify(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
|
||||
git interpret-trailers \\
|
||||
--in-place \\
|
||||
--if-exists addIfDifferent \\
|
||||
--trailer "$CO_AUTHOR_TRAILER" \\
|
||||
"$1"`;
|
||||
|
||||
return `#!/bin/sh
|
||||
set -eu
|
||||
@@ -227,7 +246,7 @@ git interpret-trailers \
|
||||
--in-place \
|
||||
--if-exists doNothing \
|
||||
--trailer "$TRAILER_NAME: $TASK_ID" \
|
||||
"$1"
|
||||
"$1"${coAuthorInjection}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -246,6 +265,9 @@ async function installCommitMsgHook(input: {
|
||||
taskId: string;
|
||||
taskPrefix: string;
|
||||
trailerName: string;
|
||||
commitAuthorEnabled?: boolean;
|
||||
commitAuthorName?: string;
|
||||
commitAuthorEmail?: string;
|
||||
}): Promise<void> {
|
||||
const hookPath = await resolveGitPath(input.worktreePath, "hooks/commit-msg");
|
||||
const existing = await fs.readFile(hookPath, "utf-8").catch(() => null);
|
||||
@@ -259,6 +281,9 @@ async function installCommitMsgHook(input: {
|
||||
const hook = buildCommitMsgTrailerHook(input.taskId, {
|
||||
taskPrefix: input.taskPrefix,
|
||||
trailerName: input.trailerName,
|
||||
commitAuthorEnabled: input.commitAuthorEnabled,
|
||||
commitAuthorName: input.commitAuthorName,
|
||||
commitAuthorEmail: input.commitAuthorEmail,
|
||||
});
|
||||
await writeFileAtomic(hookPath, hook, 0o755);
|
||||
}
|
||||
@@ -287,6 +312,9 @@ export async function installTaskWorktreeIdentityGuard(input: {
|
||||
commitMsgHookEnabled?: boolean;
|
||||
taskPrefix?: string;
|
||||
taskAttributionTrailerName?: string;
|
||||
commitAuthorEnabled?: boolean;
|
||||
commitAuthorName?: string;
|
||||
commitAuthorEmail?: string;
|
||||
}): Promise<void> {
|
||||
const hook = buildIdentityGuardHook(input.taskId, input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS);
|
||||
const metadataPath = await resolveGitPath(input.worktreePath, "fusion-task-id");
|
||||
@@ -301,6 +329,9 @@ export async function installTaskWorktreeIdentityGuard(input: {
|
||||
taskId: input.taskId,
|
||||
taskPrefix: input.taskPrefix ?? "FN",
|
||||
trailerName: input.taskAttributionTrailerName ?? "Fusion-Task-Id",
|
||||
commitAuthorEnabled: input.commitAuthorEnabled,
|
||||
commitAuthorName: input.commitAuthorName,
|
||||
commitAuthorEmail: input.commitAuthorEmail,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user