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:
Fusion
2026-05-15 13:03:46 -07:00
committed by gsxdsm
parent a5578d44da
commit 3ffc1e689e
3 changed files with 137 additions and 1 deletions

View File

@@ -5297,6 +5297,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
let filesChanged: number | undefined; let filesChanged: number | undefined;
let insertions: number | undefined; let insertions: number | undefined;
let deletions: number | undefined; let deletions: number | undefined;
let landedFiles: string[] | undefined;
const headResult = await this.runGitCommand("git rev-parse HEAD"); const headResult = await this.runGitCommand("git rev-parse HEAD");
if (headResult.exitCode === 0) { if (headResult.exitCode === 0) {
@@ -5321,8 +5322,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
deletions = undefined; deletions = undefined;
} }
if (commitSha) {
const landedFilesResult = await this.runGitCommand(`git show --name-only --format= "${commitSha}"`);
if (landedFilesResult.exitCode === 0) {
const parsedLandedFiles = landedFilesResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (parsedLandedFiles.length > 0) {
landedFiles = Array.from(new Set(parsedLandedFiles));
}
}
}
return { return {
commitSha, commitSha,
landedFiles,
filesChanged, filesChanged,
insertions, insertions,
deletions, deletions,
@@ -5447,6 +5462,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
result.merged = true; result.merged = true;
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage, mergeTarget); const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage, mergeTarget);
task.mergeDetails = mergeDetails; task.mergeDetails = mergeDetails;
if (mergeDetails.landedFiles && mergeDetails.landedFiles.length > 0) {
task.modifiedFiles = mergeDetails.landedFiles;
}
Object.assign(result, mergeDetails); Object.assign(result, mergeDetails);
} else { } else {
// Squash conflict — reset and report // Squash conflict — reset and report

View File

@@ -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();
});
});

View File

@@ -7073,6 +7073,7 @@ export async function aiMergeTask(
let filesChanged: number | undefined; let filesChanged: number | undefined;
let insertions: number | undefined; let insertions: number | undefined;
let deletions: number | undefined; let deletions: number | undefined;
let landedFiles: string[] | undefined;
try { try {
const statsCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha const statsCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
@@ -7192,9 +7193,32 @@ export async function aiMergeTask(
const recordedInsertions = mergeWasEmpty ? 0 : insertions; const recordedInsertions = mergeWasEmpty ? 0 : insertions;
const recordedDeletions = mergeWasEmpty ? 0 : deletions; 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 = { const mergeDetails: MergeDetails = {
commitSha: recordedSha, commitSha: recordedSha,
rebaseBaseSha: selectedPostMergeAuditStrategy === "rebase" && !mergeWasEmpty ? rebaseMergeBaseSha : undefined, rebaseBaseSha: selectedPostMergeAuditStrategy === "rebase" && !mergeWasEmpty ? rebaseMergeBaseSha : undefined,
landedFiles,
filesChanged: recordedFilesChanged, filesChanged: recordedFilesChanged,
insertions: recordedInsertions, insertions: recordedInsertions,
deletions: recordedDeletions, deletions: recordedDeletions,
@@ -7209,7 +7233,10 @@ export async function aiMergeTask(
autoResolvedCount: result.autoResolvedCount, autoResolvedCount: result.autoResolvedCount,
}; };
await store.updateTask(taskId, { mergeDetails }); await store.updateTask(taskId, {
mergeDetails,
modifiedFiles: landedFiles && landedFiles.length > 0 ? landedFiles : undefined,
});
if (recordedSha) { if (recordedSha) {
const currentTask = await store.getTask(taskId); const currentTask = await store.getTask(taskId);
if (currentTask?.lineageId) { if (currentTask?.lineageId) {