feat(FN-4646): complete Step 2 — capture landed files at merge time
Fusion-Task-Id: FN-4646 Fusion-Task-Lineage: f921098e-3407-40fa-984d-720e0c3248c7
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockStore, mockedCreateFnAgent, mockedExecSync, mockedExistsSync, type Task } from "./merger-test-helpers.js";
|
||||
import * as mergerModule from "../merger.js";
|
||||
|
||||
describe("FN-4646 aiMergeTask landedFiles capture", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
|
||||
});
|
||||
|
||||
function makeStore(settings: Record<string, unknown> = {}) {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-4646", worktree: "/tmp/root/.worktrees/FN-4646" },
|
||||
[{ id: "FN-4646", worktree: "/tmp/root/.worktrees/FN-4646", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as any).mockResolvedValue({ includeTaskIdInCommit: true, mergeConflictStrategy: "smart-prefer-main", ...settings });
|
||||
return store;
|
||||
}
|
||||
|
||||
it("captures squash landedFiles and overwrites modifiedFiles", async () => {
|
||||
const store = makeStore();
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const s = String(cmd);
|
||||
if (s.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "mergedsha123";
|
||||
if (s.includes("git log")) return "- feat: summary";
|
||||
if (s.includes("merge-base")) return Buffer.from("base123");
|
||||
if (s.includes("merge --squash")) return Buffer.from("");
|
||||
if (s.includes("diff --cached --quiet")) return "1";
|
||||
if (s.includes("diff --cached")) return "0";
|
||||
if (s.includes("show --shortstat --format= HEAD")) return "2 files changed, 3 insertions(+), 1 deletion(-)";
|
||||
if (s.includes("show --name-only --format= \"mergedsha123\"")) return "a.ts\nb.ts\n";
|
||||
if (s.includes("branch -d") || s.includes("branch -D") || s.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4646");
|
||||
const detailsUpdate = (store.updateTask as any).mock.calls.find((call: any[]) => call[1]?.mergeDetails?.commitSha === "mergedsha123");
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFiles).toEqual(["a.ts", "b.ts"]);
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toEqual(["a.ts", "b.ts"]);
|
||||
});
|
||||
|
||||
it("captures rebase landedFiles from rebaseBaseSha..commitSha", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const s = String(cmd);
|
||||
if (s.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "rebasesha123";
|
||||
if (s.includes("git log")) return "- feat: summary";
|
||||
if (s.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (s.includes("rev-parse \"abc123\"")) return "rebasebase123";
|
||||
if (s.includes("rev-list --reverse \"rebasebase123..fusion/FN-4646\"")) return "";
|
||||
if (s.includes("status --porcelain")) return "";
|
||||
if (s.includes("rev-parse --git-path CHERRY_PICK_HEAD")) return ".git/CHERRY_PICK_HEAD";
|
||||
if (s.includes("rev-parse --git-path sequencer")) return ".git/sequencer";
|
||||
if (s.includes("diff --shortstat \"rebasebase123..HEAD\"")) return "2 files changed, 4 insertions(+), 1 deletion(-)";
|
||||
if (s.includes("diff --name-only \"rebasebase123..rebasesha123\"")) return "c.ts\nd.ts\n";
|
||||
if (s.includes("branch -d") || s.includes("branch -D") || s.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4646");
|
||||
const detailsUpdate = (store.updateTask as any).mock.calls.find((call: any[]) => call[1]?.mergeDetails?.commitSha === "rebasesha123");
|
||||
expect(detailsUpdate?.[1].mergeDetails.rebaseBaseSha).toBe("rebasebase123");
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFiles).toEqual(["c.ts", "d.ts"]);
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toEqual(["c.ts", "d.ts"]);
|
||||
});
|
||||
|
||||
it("skips landedFiles capture for mergeWasEmpty", async () => {
|
||||
const store = makeStore();
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const s = String(cmd);
|
||||
if (s.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "mergedsha123";
|
||||
if (s.includes("git log")) return "- feat: summary";
|
||||
if (s.includes("merge-base")) return Buffer.from("base123");
|
||||
if (s.includes("merge --squash")) return Buffer.from("");
|
||||
if (s.includes("diff --cached --quiet")) return "0";
|
||||
if (s.includes("show --shortstat --format= HEAD")) return "2 files changed, 3 insertions(+), 1 deletion(-)";
|
||||
if (s.includes("branch -d") || s.includes("branch -D") || s.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await mergerModule.aiMergeTask(store, "/tmp/root", "FN-4646");
|
||||
const detailsUpdate = (store.updateTask as any).mock.calls.find((call: any[]) => call[1]?.mergeDetails);
|
||||
expect(detailsUpdate?.[1].mergeDetails.commitSha).toBeUndefined();
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFiles).toBeUndefined();
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7073,6 +7073,7 @@ export async function aiMergeTask(
|
||||
let filesChanged: number | undefined;
|
||||
let insertions: number | undefined;
|
||||
let deletions: number | undefined;
|
||||
let landedFiles: string[] | undefined;
|
||||
|
||||
try {
|
||||
const statsCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||
@@ -7192,9 +7193,32 @@ export async function aiMergeTask(
|
||||
const recordedInsertions = mergeWasEmpty ? 0 : insertions;
|
||||
const recordedDeletions = mergeWasEmpty ? 0 : deletions;
|
||||
|
||||
if (!isEmptyCommit && !mergeWasEmpty && recordedSha) {
|
||||
try {
|
||||
const landedFilesCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||
? `git diff --name-only ${quoteArg(`${rebaseMergeBaseSha}..${recordedSha}`)}`
|
||||
: `git show --name-only --format= ${quoteArg(recordedSha)}`;
|
||||
const { stdout: landedFilesOutput } = await execAsync(landedFilesCommand, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
const parsedLandedFiles = landedFilesOutput
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (parsedLandedFiles.length > 0) {
|
||||
landedFiles = Array.from(new Set(parsedLandedFiles));
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetails: MergeDetails = {
|
||||
commitSha: recordedSha,
|
||||
rebaseBaseSha: selectedPostMergeAuditStrategy === "rebase" && !mergeWasEmpty ? rebaseMergeBaseSha : undefined,
|
||||
landedFiles,
|
||||
filesChanged: recordedFilesChanged,
|
||||
insertions: recordedInsertions,
|
||||
deletions: recordedDeletions,
|
||||
@@ -7209,7 +7233,10 @@ export async function aiMergeTask(
|
||||
autoResolvedCount: result.autoResolvedCount,
|
||||
};
|
||||
|
||||
await store.updateTask(taskId, { mergeDetails });
|
||||
await store.updateTask(taskId, {
|
||||
mergeDetails,
|
||||
modifiedFiles: landedFiles && landedFiles.length > 0 ? landedFiles : undefined,
|
||||
});
|
||||
if (recordedSha) {
|
||||
const currentTask = await store.getTask(taskId);
|
||||
if (currentTask?.lineageId) {
|
||||
|
||||
Reference in New Issue
Block a user