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:
gsxdsm
2026-06-26 13:13:46 -07:00
parent fa4ee607b7
commit 31d3f21e18
11 changed files with 190 additions and 9 deletions

View 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.

View File

@@ -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. |

View File

@@ -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 });

View File

@@ -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);
});

View File

@@ -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"');

View File

@@ -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"}>"`
: "";

View File

@@ -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 ?? {};

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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,
});
}

View File

@@ -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,
});
}