feat(FN-4526): complete Step 1 — refresh post-push merge stats

Fusion-Task-Id: FN-4526
Fusion-Task-Lineage: 38dc93d6-3e56-4e22-aaaa-d09b2eed43e5
This commit is contained in:
Fusion
2026-05-14 14:22:17 -07:00
committed by gsxdsm
parent 38d017d7a4
commit 31dd80ba10
2 changed files with 141 additions and 2 deletions

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMockStore, mockedCreateFnAgent, mockedExecSync, mockedExistsSync, setupHappyPathExecSync, type Task } from "./merger-test-helpers.js";
import * as mergerModule from "../merger.js";
import { mergerLog } from "../logger.js";
describe("aiMergeTask — post-push mergeDetails stats refresh", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
});
function makeStore(initialMergeDetails: Task["mergeDetails"]) {
const store = createMockStore(
{ id: "FN-4526", worktree: "/tmp/root/.worktrees/FN-4526", mergeDetails: initialMergeDetails },
[{ id: "FN-4526", worktree: "/tmp/root/.worktrees/FN-4526", column: "in-review" } as Task],
);
const task = {
id: "FN-4526",
title: "Test",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/FN-4526",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: initialMergeDetails,
prompt: "# test",
} as Task;
(store.getTask as any).mockResolvedValue(task);
(store.getSettings as any).mockResolvedValue({ pushAfterMerge: true, pushRemote: "origin", includeTaskIdInCommit: true, mergeConflictStrategy: "smart-prefer-main" });
return store;
}
it("recomputes mergeDetails stats when post-push HEAD changes", async () => {
const store = makeStore({ commitSha: "OLD_SHA", filesChanged: 108, insertions: 200, deletions: 50, mergeConfirmed: true, mergeCommitMessage: "summary" });
mockedExecSync.mockImplementation((cmd: any) => {
const s = String(cmd);
if (s.includes("git symbolic-ref --short HEAD")) return "main";
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "NEW_SHA";
if (s.includes("show --shortstat --format= HEAD")) return "108 files changed, 200 insertions(+), 50 deletions(-)";
if (s.includes("show --shortstat --format=") && !s.includes("HEAD")) return "2 files changed, 5 insertions(+), 1 deletion(-)\n";
if (s.includes("git log")) return "- feat: summary" as any;
if (s.includes("merge-base")) return Buffer.from("base123");
if (s.includes("merge --squash")) return Buffer.from("");
if (s.includes("pull --rebase")) return "";
if (s.includes("git push origin main")) return "";
if (s.includes("diff --cached --quiet")) return "1" as any;
if (s.includes("diff --cached")) return "0" as any;
return Buffer.from("");
});
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4526");
const updates = (store.updateTask as any).mock.calls.map((c: any[]) => c[1]);
expect(updates.some((u: any) => u.mergeDetails?.commitSha === "NEW_SHA" && u.mergeDetails?.filesChanged === 2 && u.mergeDetails?.insertions === 5 && u.mergeDetails?.deletions === 1)).toBe(true);
});
it("falls back to SHA-only refresh when post-push shortstat read fails", async () => {
const warnSpy = vi.spyOn(mergerLog, "warn").mockImplementation(() => undefined);
const store = makeStore({ commitSha: "OLD_SHA", filesChanged: 108, insertions: 200, deletions: 50, mergeConfirmed: true, mergeCommitMessage: "summary" });
mockedExecSync.mockImplementation((cmd: any) => {
const s = String(cmd);
if (s.includes("git symbolic-ref --short HEAD")) return "main";
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "NEW_SHA";
if (s.includes("show --shortstat --format= HEAD")) return "108 files changed, 200 insertions(+), 50 deletions(-)";
if (s.includes("show --shortstat --format=") && !s.includes("HEAD")) throw new Error("shortstat failed");
if (s.includes("git log")) return "- feat: summary" as any;
if (s.includes("merge-base")) return Buffer.from("base123");
if (s.includes("merge --squash")) return Buffer.from("");
if (s.includes("pull --rebase")) return "";
if (s.includes("git push origin main")) return "";
if (s.includes("diff --cached --quiet")) return "1" as any;
if (s.includes("diff --cached")) return "0" as any;
return Buffer.from("");
});
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4526");
const updates = (store.updateTask as any).mock.calls.map((c: any[]) => c[1]);
expect(updates.some((u: any) => u.mergeDetails?.commitSha === "NEW_SHA" && u.mergeDetails?.filesChanged === 108 && u.mergeDetails?.insertions === 200 && u.mergeDetails?.deletions === 50)).toBe(true);
expect(warnSpy.mock.calls.some(([msg]) => String(msg).includes("post-push SHA refreshed but stat recompute failed"))).toBe(true);
});
it("does not write a post-push refresh when HEAD SHA is unchanged", async () => {
const store = makeStore({ commitSha: "SAME_SHA", filesChanged: 108, insertions: 200, deletions: 50, mergeConfirmed: true, mergeCommitMessage: "summary" });
mockedExecSync.mockImplementation((cmd: any) => {
const s = String(cmd);
if (s.includes("git symbolic-ref --short HEAD")) return "main";
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "SAME_SHA";
if (s.includes("show --shortstat --format= HEAD")) return "108 files changed, 200 insertions(+), 50 deletions(-)";
if (s.includes("git log")) return "- feat: summary" as any;
if (s.includes("merge-base")) return Buffer.from("base123");
if (s.includes("merge --squash")) return Buffer.from("");
if (s.includes("pull --rebase")) return "";
if (s.includes("git push origin main")) return "";
if (s.includes("diff --cached --quiet")) return "1" as any;
if (s.includes("diff --cached")) return "0" as any;
return Buffer.from("");
});
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4526");
const updates = (store.updateTask as any).mock.calls.map((c: any[]) => c[1]);
expect(updates.some((u: any) => u.mergeDetails?.commitSha === "SAME_SHA" && u.mergeDetails?.filesChanged === 108)).toBe(true);
expect(updates.some((u: any) => u.mergeDetails?.commitSha === "SAME_SHA" && u.mergeDetails?.filesChanged === 2)).toBe(false);
});
});

View File

@@ -7294,11 +7294,41 @@ export async function aiMergeTask(
const existingTask = await store.getTask(taskId).catch(() => null);
const existingDetails = existingTask?.mergeDetails;
if (existingDetails?.commitSha && existingDetails.commitSha !== postPushSha) {
let updatedStats = {
filesChanged: existingDetails.filesChanged,
insertions: existingDetails.insertions,
deletions: existingDetails.deletions,
};
try {
const { stdout: postPushStatsOutput } = await execAsync(
`git show --shortstat --format= ${quoteArg(postPushSha)}`,
{ cwd: rootDir, encoding: "utf-8" },
);
const normalized = postPushStatsOutput.trim().replace(/\n/g, " ");
const filesMatch = normalized.match(/(\d+) files? changed/);
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
updatedStats = {
filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0,
};
} catch (statsErr: unknown) {
const statsErrMessage = statsErr instanceof Error ? statsErr.message : String(statsErr);
mergerLog.warn(`${taskId}: post-push SHA refreshed but stat recompute failed: ${statsErrMessage}`);
}
await store.updateTask(taskId, {
mergeDetails: { ...existingDetails, commitSha: postPushSha },
mergeDetails: {
...existingDetails,
commitSha: postPushSha,
filesChanged: updatedStats.filesChanged,
insertions: updatedStats.insertions,
deletions: updatedStats.deletions,
},
});
mergerLog.log(
`${taskId}: post-push HEAD changed from ${existingDetails.commitSha.slice(0, 8)} to ${postPushSha.slice(0, 8)} — refreshed mergeDetails.commitSha`,
`${taskId}: post-push HEAD changed from ${existingDetails.commitSha.slice(0, 8)} to ${postPushSha.slice(0, 8)} — refreshed mergeDetails.commitSha (stats: ${updatedStats.filesChanged ?? 0}f/${updatedStats.insertions ?? 0}i/${updatedStats.deletions ?? 0}d, was ${existingDetails.filesChanged ?? 0}f/${existingDetails.insertions ?? 0}i/${existingDetails.deletions ?? 0}d)`,
);
}
}