test(FN-5103): complete Step 6 — add landed-files attribution reliability coverage
Fusion-Task-Id: FN-5103 Fusion-Task-Lineage: e4e4d9ba-4884-4feb-b18b-a30e799c3fb1
This commit is contained in:
committed by
gsxdsm
parent
20ab2f0172
commit
03212ce00b
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BranchAttributionError } from "../branch-attribution.js";
|
||||
import * as attributionModule from "../branch-attribution.js";
|
||||
import { createMockStore, mockedCreateFnAgent, mockedExecSync, mockedExistsSync, type Task } from "./merger-test-helpers.js";
|
||||
import * as mergerModule from "../merger.js";
|
||||
|
||||
@@ -41,8 +43,15 @@ describe("FN-4646 aiMergeTask landedFiles capture", () => {
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toEqual(["a.ts", "b.ts"]);
|
||||
});
|
||||
|
||||
it("captures rebase landedFiles from rebaseBaseSha..commitSha", async () => {
|
||||
it("FN-5052 regression: rebase walking 66 foreign commits attributes only the 1 own commit", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
files: ["packages/engine/src/self-healing.ts"],
|
||||
foreignCommits: Array.from({ length: 66 }, (_, i) => `foreign-${i}`),
|
||||
ownCommitCount: 1,
|
||||
ownCommitShas: ["ownsha1"],
|
||||
rawDiffFileCount: 67,
|
||||
});
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const s = String(cmd);
|
||||
if (s.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
@@ -54,8 +63,7 @@ describe("FN-4646 aiMergeTask landedFiles capture", () => {
|
||||
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("show --shortstat --format= \"ownsha1\"")) return "1 file changed, 3 insertions(+)";
|
||||
if (s.includes("branch -d") || s.includes("branch -D") || s.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
@@ -63,8 +71,62 @@ describe("FN-4646 aiMergeTask landedFiles capture", () => {
|
||||
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(["packages/engine/src/self-healing.ts"]);
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFilesAttributionRestricted).toBe(true);
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toEqual(["packages/engine/src/self-healing.ts"]);
|
||||
});
|
||||
|
||||
it("FN-5052 short-circuit variant: zero own commits yields empty landed files and keeps modifiedFiles", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
files: [], foreignCommits: Array.from({ length: 66 }, (_, i) => `foreign-${i}`), ownCommitCount: 0, ownCommitShas: [], rawDiffFileCount: 66,
|
||||
});
|
||||
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("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.landedFiles).toEqual([]);
|
||||
expect(detailsUpdate?.[1].mergeDetails.noOpVerifiedShortCircuit).toBe(true);
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to legacy rebase capture when attribution fails", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockRejectedValue(new BranchAttributionError("boom"));
|
||||
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 --name-only \"rebasebase123..rebasesha123\"")) return "c.ts\nd.ts\n";
|
||||
if (s.includes("diff --shortstat \"rebasebase123..HEAD\"")) return "2 files changed, 4 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?.commitSha === "rebasesha123");
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFiles).toEqual(["c.ts", "d.ts"]);
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toEqual(["c.ts", "d.ts"]);
|
||||
expect(detailsUpdate?.[1].mergeDetails.landedFilesCaptureFallback).toBe("attribution-failed");
|
||||
});
|
||||
|
||||
it("skips landedFiles capture for mergeWasEmpty", async () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "../../branch-attribution.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(cwd: string, command: string): string {
|
||||
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
async function commitFile(cwd: string, file: string, content: string, message: string, taskId?: string): Promise<string> {
|
||||
await writeFile(join(cwd, file), content, "utf-8");
|
||||
git(cwd, `git add ${JSON.stringify(file)}`);
|
||||
if (taskId) {
|
||||
git(cwd, `git commit -m ${JSON.stringify(message)} -m ${JSON.stringify(`Fusion-Task-Id: ${taskId}`)}`);
|
||||
} else {
|
||||
git(cwd, `git commit -m ${JSON.stringify(message)}`);
|
||||
}
|
||||
return git(cwd, "git rev-parse HEAD");
|
||||
}
|
||||
|
||||
async function initRepo(prefix: string) {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), prefix));
|
||||
git(repoDir, "git init -b main");
|
||||
git(repoDir, 'git config user.email "test@example.com"');
|
||||
git(repoDir, 'git config user.name "Test User"');
|
||||
await commitFile(repoDir, "README.md", "base\n", "chore: init", "FN-BASE");
|
||||
const baseSha = git(repoDir, "git rev-parse HEAD");
|
||||
return { repoDir, baseSha };
|
||||
}
|
||||
|
||||
describeIfGit("FN-5103 reliability interaction: landed-files attribution", () => {
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("captures only own-commit files when rebased over foreign commits", async () => {
|
||||
const { repoDir, baseSha } = await initRepo("fn-5103-ri-");
|
||||
dirs.push(repoDir);
|
||||
const taskId = "FN-5103";
|
||||
|
||||
git(repoDir, `git checkout -b fusion/${taskId.toLowerCase()}`);
|
||||
const own1 = await commitFile(repoDir, "task-a.ts", "a\n", "feat(FN-5103): A", taskId);
|
||||
const own2 = await commitFile(repoDir, "task-b.ts", "b\n", "feat(FN-5103): B", taskId);
|
||||
const own3 = await commitFile(repoDir, "task-c.ts", "c\n", "feat(FN-5103): C", taskId);
|
||||
|
||||
git(repoDir, "git checkout main");
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await commitFile(repoDir, `other-${i}.ts`, `x${i}\n`, `feat(FN-OTHER-${i}): other`, `FN-OTHER-${i}`);
|
||||
}
|
||||
|
||||
git(repoDir, `git checkout fusion/${taskId.toLowerCase()}`);
|
||||
git(repoDir, "git rebase main");
|
||||
|
||||
const attribution = await filterFilesToOwnTaskCommits({ worktreePath: repoDir, baseRef: baseSha, taskId });
|
||||
expect(attribution.files).toEqual(["task-a.ts", "task-b.ts", "task-c.ts"]);
|
||||
expect(attribution.ownCommitCount).toBe(3);
|
||||
expect(attribution.foreignCommits.length).toBe(5);
|
||||
expect(attribution.ownCommitShas).toHaveLength(3);
|
||||
expect(new Set(attribution.ownCommitShas ?? []).size).toBe(3);
|
||||
expect([own1, own2, own3]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("marks verified short-circuit shape when no own commits are attributable", async () => {
|
||||
const { repoDir, baseSha } = await initRepo("fn-5103-ri-");
|
||||
dirs.push(repoDir);
|
||||
const taskId = "FN-5103";
|
||||
|
||||
git(repoDir, `git checkout -b fusion/${taskId.toLowerCase()}`);
|
||||
await commitFile(repoDir, "foreign-only.ts", "x\n", "feat(FN-OTHER): foreign", "FN-OTHER");
|
||||
|
||||
const attribution = await filterFilesToOwnTaskCommits({ worktreePath: repoDir, baseRef: baseSha, taskId });
|
||||
expect(attribution.files).toEqual([]);
|
||||
expect(attribution.ownCommitCount).toBe(0);
|
||||
expect(attribution.foreignCommits.length).toBe(1);
|
||||
});
|
||||
|
||||
it("supports attribution failure fallback signal path", async () => {
|
||||
const error = new BranchAttributionError("synthetic attribution failure");
|
||||
expect(error.message).toContain("synthetic attribution failure");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user