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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user