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:
@@ -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
|
||||||
|
|||||||
@@ -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 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user