FN-5646: preserve AI body when canonicalizing merge commits

Fusion-Task-Id: FN-5646

Fusion-Task-Lineage: db989a0f-a7d9-4060-9754-84d71f21a8c2
This commit is contained in:
gsxdsm
2026-05-29 00:55:17 -07:00
parent b8c8642cae
commit c89ad28e7b
12 changed files with 152 additions and 2 deletions

View File

@@ -13,7 +13,10 @@ vi.mock("node:child_process", () => ({
execFile: vi.fn(),
}));
import { composeMergeCommitBody } from "../merger.js";
import {
composeMergeCommitBody,
__testOnlyBuildDeterministicMergeMessage as buildDeterministicMergeMessage,
} from "../merger.js";
describe("composeMergeCommitBody", () => {
const commitLog = "- feat: one";
@@ -23,6 +26,15 @@ describe("composeMergeCommitBody", () => {
expect(composeMergeCommitBody({ branch: "fusion/FN-1", commitLog, diffStat })).toBe(
"Commits merged:\n- feat: one\n\nFiles changed:\n1 file changed",
);
expect(composeMergeCommitBody({ branch: "fusion/FN-1", commitLog, diffStat, aiBody: undefined })).toBe(
"Commits merged:\n- feat: one\n\nFiles changed:\n1 file changed",
);
expect(composeMergeCommitBody({ branch: "fusion/FN-1", commitLog, diffStat, aiBody: null })).toBe(
"Commits merged:\n- feat: one\n\nFiles changed:\n1 file changed",
);
expect(composeMergeCommitBody({ branch: "fusion/FN-1", commitLog, diffStat, aiBody: " " })).toBe(
"Commits merged:\n- feat: one\n\nFiles changed:\n1 file changed",
);
});
it("combines AI narrative + bullets + files changed", () => {
@@ -45,3 +57,65 @@ describe("composeMergeCommitBody", () => {
.toBe("- bullet one\n\nFiles changed:\n1 file changed");
});
});
describe("buildDeterministicMergeMessage", () => {
const decodeArg = (arg: string) => arg.replace(/^-m\s+"/, "").replace(/"$/, "").replace(/\\(["\\$`])/g, "$1");
it("preserves AI body bullets in canonical merge body", async () => {
const { bodyArg } = await buildDeterministicMergeMessage({
taskId: "FN-1",
branch: "fusion/FN-1",
commitLog: "- feat: one",
diffStat: "1 file changed",
includeTaskId: true,
aiSummary: "Narrative summary.",
aiBody: "- bullet one\n- bullet two",
aiSubject: "tighten canonical merge message",
});
const body = decodeArg(bodyArg);
expect(body).toContain("- bullet one\n- bullet two");
expect(body).toContain("Files changed:\n1 file changed");
});
it("falls back deterministically when aiBody is null/empty", async () => {
for (const aiBody of [undefined, null, " "] as const) {
const { bodyArg } = await buildDeterministicMergeMessage({
taskId: "FN-3",
branch: "fusion/FN-3",
commitLog: "- feat: one",
diffStat: "1 file changed",
includeTaskId: true,
aiSummary: "Narrative summary.",
aiBody,
aiSubject: "subject",
});
expect(decodeArg(bodyArg)).toBe("Narrative summary.\n\nFiles changed:\n1 file changed");
}
});
it("keeps subject behavior unchanged when aiBody is present", async () => {
const withSubject = await buildDeterministicMergeMessage({
taskId: "FN-1",
branch: "fusion/FN-1",
commitLog: "- feat: one",
diffStat: "1 file changed",
includeTaskId: true,
aiSummary: "Narrative",
aiBody: "- bullet",
aiSubject: "custom subject",
});
expect(withSubject.subjectArg).toContain("feat(FN-1): custom subject");
const fallbackSubject = await buildDeterministicMergeMessage({
taskId: "FN-2",
branch: "fusion/FN-2",
commitLog: "",
diffStat: "",
includeTaskId: false,
aiSummary: null,
aiBody: "- bullet",
aiSubject: null,
});
expect(fallbackSubject.subjectArg).toContain("feat: merge fusion/FN-2");
});
});

View File

@@ -48,6 +48,7 @@ function runFinalize(dir: string, taskId: string, branch: string, preAttemptHead
undefined,
null,
null,
null,
new Set<string>(),
);
}

View File

@@ -358,6 +358,7 @@ describe("diff-volume gate merger integration", () => {
undefined,
null,
null,
null,
new Set<string>(),
createMockStore(),
)).rejects.toBeInstanceOf(DiffVolumeRegressionError);
@@ -398,6 +399,7 @@ describe("diff-volume gate merger integration", () => {
undefined,
null,
null,
null,
new Set<string>(["README.md"]),
createMockStore(),
)).rejects.toBeInstanceOf(DiffVolumeRegressionError);

View File

@@ -480,6 +480,7 @@ describe("file-scope invariant wiring", () => {
undefined,
undefined,
undefined,
undefined,
new Set(),
store as never,
)).resolves.toMatchObject({ ok: true, reason: "committed" });

View File

@@ -104,10 +104,46 @@ describe("commitOrAmendMergeWithFixes gitignored guard", () => {
undefined,
null,
null,
null,
new Set(),
);
expect(result).toEqual({ ok: false, reason: "fix-produced-no-content" });
expect(git(dir, "git diff --cached --name-only").trim()).toBe("");
});
it("preserves aiBody in finalize commit message", async () => {
const dir = mkRepo();
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/body");
writeFileSync(join(dir, "feature.txt"), "hello\n");
git(dir, "git add feature.txt");
git(dir, 'git commit -m "feat: add feature file"');
git(dir, "git checkout main");
git(dir, "git merge --squash feat/body");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-4309",
"feat/body",
"- feat: add feature file",
true,
preAttemptHeadSha,
"",
"1 file changed",
{ ...DEFAULT_SETTINGS, mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
undefined,
"Narrative line.",
"- kept bullet one\n- kept bullet two",
"preserve body",
new Set(),
);
expect(result).toEqual({ ok: true, reason: "committed" });
const fullMessage = git(dir, "git log -1 --pretty=%B");
expect(fullMessage).toContain("Narrative line.");
expect(fullMessage).toContain("- kept bullet one");
expect(fullMessage).toContain("- kept bullet two");
});
});

View File

@@ -81,6 +81,7 @@ describe("commitOrAmendMergeWithFixes no-op finalize", () => {
undefined,
null,
null,
null,
new Set<string>(),
);
@@ -109,6 +110,7 @@ describe("commitOrAmendMergeWithFixes no-op finalize", () => {
undefined,
null,
null,
null,
new Set<string>(),
);

View File

@@ -918,6 +918,7 @@ describe("commitOrAmendMergeWithFixes", () => {
undefined,
null,
null,
null,
new Set(),
);
@@ -964,6 +965,7 @@ describe("commitOrAmendMergeWithFixes", () => {
undefined,
null,
null,
null,
new Set(),
store,
);
@@ -1004,6 +1006,7 @@ describe("commitOrAmendMergeWithFixes", () => {
undefined,
null,
null,
null,
new Set(),
);

View File

@@ -235,6 +235,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set<string>(), // empty fixModifiedFiles
);
@@ -278,6 +279,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set(["README.md"]), // fix agent touched this
);
@@ -316,6 +318,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set(["feature-c.ts"]), // fix agent touched the same file the squash staged
);
@@ -355,6 +358,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set(["new-fixture.ts"]), // fix agent created this file
);
@@ -390,6 +394,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set<string>(), // empty — the WIP file is not fix-agent-produced
);
@@ -442,6 +447,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set(["README.md"]), // only the agent's file is in the allowlist
);
@@ -502,6 +508,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
undefined,
null,
null,
null,
new Set<string>(), // fix touched no tracked files
);
@@ -627,6 +634,7 @@ describe("commitOrAmendMergeWithFixes — embedded-space paths round-trip", () =
undefined,
null,
null,
null,
new Set([spacedPath]), // fix agent touched this tracked file
);
@@ -671,6 +679,7 @@ describe("commitOrAmendMergeWithFixes — embedded-space paths round-trip", () =
undefined,
null,
null,
null,
new Set<string>(), // empty allowlist
);

View File

@@ -70,6 +70,7 @@ describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
undefined,
null,
null,
null,
new Set(),
);

View File

@@ -134,6 +134,13 @@ describe("post-finalize verification failure reliability interactions (real git)
true,
preAttemptHeadSha,
"",
undefined,
undefined,
undefined,
null,
null,
null,
new Set<string>(),
);
expect(finalized.ok && finalized.reason === "branch-already-merged-on-main").toBe(true);

View File

@@ -60,6 +60,13 @@ describe("verification-fix already-on-main reliability interactions (real git)",
true,
preAttemptHeadSha,
"",
undefined,
undefined,
undefined,
null,
null,
null,
new Set<string>(),
);
expect(finalized.ok && finalized.reason === "branch-already-merged-on-main").toBe(true);
if (finalized.ok && finalized.reason === "branch-already-merged-on-main") {

View File

@@ -3981,6 +3981,8 @@ async function buildDeterministicMergeMessage(params: {
};
}
export { buildDeterministicMergeMessage as __testOnlyBuildDeterministicMergeMessage };
/**
* Stage current changes and either:
* (a) create a fresh squash commit when HEAD has not advanced past
@@ -4142,6 +4144,7 @@ export async function commitOrAmendMergeWithFixes(
settings?: Settings,
signal?: AbortSignal,
aiSummary?: string | null,
aiBody?: string | null,
aiSubject?: string | null,
fixModifiedFiles: ReadonlySet<string> = new Set(),
store?: TaskStore,
@@ -4505,7 +4508,7 @@ export async function commitOrAmendMergeWithFixes(
diffStat: messageDiffStat,
includeTaskId,
aiSummary,
aiBody: undefined,
aiBody,
aiSubject,
});
let lineageId: string | undefined;
@@ -9262,6 +9265,7 @@ export async function aiMergeTask(
settings,
options.signal,
aiMergeSummary,
aiMergeBody,
aiMergeSubject,
verificationFixModifiedFiles,
store,
@@ -9410,6 +9414,7 @@ export async function aiMergeTask(
settings,
options.signal,
aiMergeSummary,
aiMergeBody,
aiMergeSubject,
buildFixModifiedFiles,
store,
@@ -10649,6 +10654,7 @@ export async function executeMergeAttempt(
diffStat,
includeTaskId,
aiSummary: safeBody,
aiBody: aiBody?.trim().length ? aiBody : safeBody,
aiSubject,
});
await enforceSquashFileScopeInvariant({
@@ -10894,6 +10900,7 @@ export async function executeMergeAttempt(
diffStat: actualContext.diffStat || diffStat,
includeTaskId,
aiSummary,
aiBody,
aiSubject,
});
const trailerArg = buildTaskTrailerArgs(taskId);