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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ function runFinalize(dir: string, taskId: string, branch: string, preAttemptHead
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Set<string>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -480,6 +480,7 @@ describe("file-scope invariant wiring", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new Set(),
|
||||
store as never,
|
||||
)).resolves.toMatchObject({ ok: true, reason: "committed" });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user