feat(FN-4308): complete Step 2 — aggregate done-task diffs by lineage commits

Fusion-Task-Id: FN-4308
Fusion-Task-Lineage: d0a46a8f-2245-4ddc-99b4-06d78e87db16
This commit is contained in:
Fusion
2026-05-13 09:10:24 -07:00
committed by gsxdsm
parent b63457d085
commit bd1069c4e6
2 changed files with 315 additions and 188 deletions

View File

@@ -1,7 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import type { Task, TaskCommitAssociation } from "@fusion/core";
import * as fs from "node:fs";
const runGitCommandMock = vi.fn<(...args: any[]) => Promise<string>>();
vi.mock("../routes/resolve-diff-base.js", () => ({
resolveDiffBase: vi.fn(async () => "origin/main"),
runGitCommand: (...args: any[]) => runGitCommandMock(...args),
}));
import { createServer } from "../server.js";
vi.mock("node:fs", async () => {
@@ -16,6 +24,7 @@ const mockExistsSync = vi.mocked(fs.existsSync);
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
private associations = new Map<string, TaskCommitAssociation[]>();
getRootDir(): string {
return "/tmp/fn-679";
@@ -59,6 +68,14 @@ class MockStore extends EventEmitter {
addTask(task: Task): void {
this.tasks.set(task.id, task);
}
setAssociations(lineageId: string, associations: TaskCommitAssociation[]): void {
this.associations.set(lineageId, associations);
}
async getTaskCommitAssociationsByLineageId(lineageId: string): Promise<TaskCommitAssociation[]> {
return this.associations.get(lineageId) ?? [];
}
}
function createTask(overrides: Partial<Task> = {}): Task {
@@ -80,13 +97,40 @@ function createTask(overrides: Partial<Task> = {}): Task {
};
}
async function requestDiff(app: Parameters<typeof import("../test-request.js").get>[0], taskId = "FN-679", worktree?: string): Promise<{ status: number; body: any }> {
async function requestDiff(app: Parameters<typeof import("../test-request.js").get>[0], taskId = "FN-679"): Promise<{ status: number; body: any }> {
const { get } = await import("../test-request.js");
const url = `/api/tasks/${taskId}/diff${worktree ? `?worktree=${encodeURIComponent(worktree)}` : ""}`;
return get(app, url);
return get(app, `/api/tasks/${taskId}/diff`);
}
describe("GET /api/tasks/:id/diff", () => {
async function requestFileDiffs(app: Parameters<typeof import("../test-request.js").get>[0], taskId = "FN-679"): Promise<{ status: number; body: any }> {
const { get } = await import("../test-request.js");
return get(app, `/api/tasks/${taskId}/file-diffs`);
}
function gitResponses(entries: Record<string, string>) {
runGitCommandMock.mockImplementation(async (args: string[]) => {
const key = args.join(" ");
if (key in entries) return entries[key] ?? "";
throw new Error(`Unexpected git command: ${key}`);
});
}
function makeAssociation(sha: string, authoredAt: string): TaskCommitAssociation {
return {
lineageId: "lin-1",
commitSha: sha,
commitSubject: sha,
authoredAt,
matchedBy: "manual",
confidence: 1,
taskIdSnapshot: "FN-679",
note: null,
createdAt: authoredAt,
updatedAt: authoredAt,
};
}
describe("FN-4308 multi-commit done task aggregation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
@@ -96,173 +140,118 @@ describe("GET /api/tasks/:id/diff", () => {
vi.restoreAllMocks();
});
it("returns 404 when task not found", async () => {
it("aggregates union of files for /diff and /file-diffs", async () => {
const store = new MockStore();
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "c3" } }));
store.setAssociations("lin-1", [makeAssociation("c1", "2026-04-01T00:00:00.000Z"), makeAssociation("c2", "2026-04-01T00:01:00.000Z"), makeAssociation("c3", "2026-04-01T00:02:00.000Z")]);
gitResponses({
"merge-base --is-ancestor c1 HEAD": "",
"merge-base --is-ancestor c2 HEAD": "",
"merge-base --is-ancestor c3 HEAD": "",
"rev-parse c1^": "p1",
"diff --name-status p1..c1": "A\ta.txt\nM\tb.txt",
"diff p1..c1 -- a.txt": "+a\n",
"diff p1..c1 -- b.txt": "+b\n",
"rev-parse c2^": "p2",
"diff --name-status p2..c2": "M\tb.txt\nA\tc.txt",
"diff p2..c2 -- b.txt": "+bb\n-b\n",
"diff p2..c2 -- c.txt": "+c\n",
"rev-parse c3^": "p3",
"diff --name-status p3..c3": "A\td.txt",
"diff p3..c3 -- d.txt": "+d\n",
});
const app = createServer(store as any);
const response = await requestDiff(app, "NONEXISTENT");
const diffResponse = await requestDiff(app);
expect(diffResponse.status).toBe(200);
expect(diffResponse.body.stats.filesChanged).toBe(4);
expect(diffResponse.body.files.map((f: any) => f.path).sort()).toEqual(["a.txt", "b.txt", "c.txt", "d.txt"]);
expect(response.status).toBe(404);
}, 15_000);
const fileDiffsResponse = await requestFileDiffs(app);
expect(fileDiffsResponse.status).toBe(200);
expect(fileDiffsResponse.body.map((f: any) => f.path).sort()).toEqual(["a.txt", "b.txt", "c.txt", "d.txt"]);
});
it("handler can be created with valid task", async () => {
it("single-commit lineage matches existing behavior", async () => {
const store = new MockStore();
store.addTask(createTask({ baseBranch: "develop" }));
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "c1" } }));
store.setAssociations("lin-1", [makeAssociation("c1", "2026-04-01T00:00:00.000Z")]);
gitResponses({
"merge-base --is-ancestor c1 HEAD": "",
"rev-parse c1^": "p1",
"diff --name-status p1..c1": "A\tone.txt",
"diff p1..c1 -- one.txt": "+one\n",
});
const app = createServer(store as any);
const response = await requestDiff(app);
// Should return 200 or 500 depending on git command results
expect([200, 500]).toContain(response.status);
});
});
describe("GET /api/tasks/:id/diff — done tasks", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
expect(response.status).toBe(200);
expect(response.body.stats.filesChanged).toBe(1);
expect(response.body.files).toHaveLength(1);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns empty result when rev-parse sha^ fails", async () => {
it("falls back to merge commit range when lineage associations are empty", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "done",
mergeDetails: { commitSha: "broken_sha" },
}));
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "m1" } }));
store.setAssociations("lin-1", []);
gitResponses({
"merge-base --is-ancestor m1 HEAD": "",
"rev-parse m1^": "pm1",
"diff pm1..m1": "+x\n-y\n",
"diff --name-only pm1..m1": "x.txt\n",
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toEqual([]);
expect(response.body.stats.filesChanged).toBe(1);
});
it("returns empty result for done task without commitSha", async () => {
it("skips unreachable lineage SHAs and still aggregates reachable commits", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "done",
mergeDetails: undefined,
}));
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "good" } }));
store.setAssociations("lin-1", [makeAssociation("bad", "2026-04-01T00:00:00.000Z"), makeAssociation("good", "2026-04-01T00:01:00.000Z")]);
runGitCommandMock.mockImplementation(async (args: string[]) => {
const key = args.join(" ");
if (key === "merge-base --is-ancestor bad HEAD") throw new Error("unreachable");
if (key === "merge-base --is-ancestor good HEAD") return "";
if (key === "rev-parse good^") return "p";
if (key === "diff --name-status p..good") return "A\treachable.txt";
if (key === "diff p..good -- reachable.txt") return "+ok\n";
throw new Error(`Unexpected git command: ${key}`);
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toEqual([]);
});
});
describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns empty diff when task.worktree is null and no worktree query param", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "in-progress",
worktree: null as any,
}));
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body).toEqual({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
});
it("returns empty diff when task.worktree is undefined", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "in-progress",
worktree: undefined,
}));
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body).toEqual({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
});
it("returns empty diff when worktree path does not exist on disk", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "in-progress",
worktree: "/tmp/nonexistent-worktree",
}));
// Mock existsSync to return false for the worktree path
mockExistsSync.mockImplementation((path: unknown) => {
if (typeof path === "string" && path === "/tmp/nonexistent-worktree") {
return false;
}
return true;
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body).toEqual({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
});
it("returns diff when worktree path exists (regression guard)", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "in-progress",
worktree: "/tmp/fn-679",
}));
mockExistsSync.mockReturnValue(true);
const app = createServer(store as any);
const response = await requestDiff(app);
// Should return 200 or 500 depending on git command results (happy path)
expect([200, 500]).toContain(response.status);
});
it("returns empty diff when worktree query param path does not exist", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "in-progress",
worktree: "/tmp/fn-679",
}));
// Mock existsSync to return false for the query param worktree
mockExistsSync.mockImplementation((path: unknown) => {
if (typeof path === "string" && path === "/tmp/query-worktree-does-not-exist") {
return false;
}
return true;
});
const app = createServer(store as any);
const response = await requestDiff(app, "FN-679", "/tmp/query-worktree-does-not-exist");
expect(response.status).toBe(200);
expect(response.body).toEqual({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
expect(response.body.stats.filesChanged).toBe(1);
expect(response.body.files[0].path).toBe("reachable.txt");
});
it("includes mergeDetails.commitSha even when missing from associations", async () => {
const store = new MockStore();
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "merge-only" } }));
store.setAssociations("lin-1", [makeAssociation("assoc-1", "2026-04-01T00:00:00.000Z")]);
runGitCommandMock.mockImplementation(async (args: string[]) => {
const key = args.join(" ");
if (key === "merge-base --is-ancestor assoc-1 HEAD") throw new Error("unreachable");
if (key === "merge-base --is-ancestor merge-only HEAD") return "";
if (key === "rev-parse merge-only^") return "p";
if (key === "diff --name-status p..merge-only") return "A\tmerged.txt";
if (key === "diff p..merge-only -- merged.txt") return "+ok\n";
throw new Error(`Unexpected git command: ${key}`);
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.stats.filesChanged).toBe(1);
expect(response.body.files[0].path).toBe("merged.txt");
});
});

View File

@@ -39,6 +39,151 @@ const fileDiffsCache = new Map<
}
>();
type DoneTaskFileStatus = "added" | "modified" | "deleted" | "renamed";
type AggregatedDoneTaskFile = {
path: string;
status: DoneTaskFileStatus;
additions: number;
deletions: number;
patch: string;
};
function statusPriority(status: DoneTaskFileStatus): number {
switch (status) {
case "added":
return 4;
case "modified":
return 3;
case "renamed":
return 2;
case "deleted":
return 1;
default:
return 0;
}
}
async function isReachableFromHead(sha: string, rootDir: string): Promise<boolean> {
try {
await runGitCommand(["merge-base", "--is-ancestor", sha, "HEAD"], rootDir, 5000);
return true;
} catch {
return false;
}
}
async function collectDoneTaskFiles(task: any, scopedStore: ProjectContext["store"]): Promise<{
files: AggregatedDoneTaskFile[];
stats: { filesChanged: number; additions: number; deletions: number };
usedAggregation: boolean;
}> {
const rootDir = scopedStore.getRootDir();
const mergeSha = task.mergeDetails?.commitSha;
const orderedShas: string[] = [];
if (task.lineageId) {
const associations = await scopedStore.getTaskCommitAssociationsByLineageId(task.lineageId);
const sorted = [...associations].sort((a, b) => {
const left = a.authoredAt ? Date.parse(a.authoredAt) : Number.POSITIVE_INFINITY;
const right = b.authoredAt ? Date.parse(b.authoredAt) : Number.POSITIVE_INFINITY;
return left - right;
});
for (const association of sorted) {
if (association.commitSha && !orderedShas.includes(association.commitSha)) {
orderedShas.push(association.commitSha);
}
}
}
if (mergeSha && !orderedShas.includes(mergeSha)) {
orderedShas.push(mergeSha);
}
const reachableShas: string[] = [];
for (const sha of orderedShas) {
if (await isReachableFromHead(sha, rootDir)) {
reachableShas.push(sha);
}
}
if (reachableShas.length === 0) {
return {
files: [],
stats: {
filesChanged: 0,
additions: 0,
deletions: 0,
},
usedAggregation: false,
};
}
const byPath = new Map<string, AggregatedDoneTaskFile>();
for (const sha of reachableShas) {
let parentSha: string;
try {
parentSha = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
continue;
}
let nameStatus = "";
try {
nameStatus = (await runGitCommand(["diff", "--name-status", `${parentSha}..${sha}`], rootDir, 10000)).trim();
} catch {
continue;
}
for (const line of nameStatus.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = statusCode.startsWith("R") ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
if (!filePath) continue;
let status: DoneTaskFileStatus = "modified";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else if (statusCode.startsWith("R")) status = "renamed";
let patch = "";
try {
patch = await runGitCommand(["diff", `${parentSha}..${sha}`, "--", filePath], rootDir, 10000);
} catch {
patch = "";
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
const existing = byPath.get(filePath);
if (!existing) {
byPath.set(filePath, { path: filePath, status, additions, deletions, patch });
continue;
}
existing.additions += additions;
existing.deletions += deletions;
existing.patch = `${existing.patch}${existing.patch && patch ? "\n" : ""}${patch}`;
if (statusPriority(status) > statusPriority(existing.status)) {
existing.status = status;
}
}
}
const files = Array.from(byPath.values());
return {
files,
stats: {
filesChanged: files.length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
},
usedAggregation: true,
};
}
/**
* Registers task session-file and diff routes.
*
@@ -150,11 +295,23 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
if (task.column === "done" && task.mergeDetails?.commitSha) {
const aggregated = await collectDoneTaskFiles(task, scopedStore);
if (aggregated.usedAggregation && aggregated.files.length > 0) {
res.json({
files: aggregated.files.map((file) => ({
...file,
status: file.status === "renamed" ? "modified" : file.status,
})),
stats: aggregated.stats,
});
return;
}
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
@@ -162,45 +319,19 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
return;
}
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 10000)).trim();
const patch = await runGitCommand(["diff", `${mergeBase}..${sha}`], rootDir, 10000).catch(() => "");
const filesChanged = (await runGitCommand(["diff", "--name-only", `${mergeBase}..${sha}`], rootDir, 10000)
.then((output) => output.split("\n").filter(Boolean).length)
.catch(() => 0));
const doneFiles: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}> = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
if (!filePath) continue;
let status: "added" | "modified" | "deleted" = "modified";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
let patch = "";
try {
patch = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 10000);
} catch {
// ignore
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
doneFiles.push({ path: filePath, status, additions, deletions, patch });
}
const doneStats = {
filesChanged: doneFiles.length,
additions: doneFiles.reduce((s, f) => s + f.additions, 0),
deletions: doneFiles.reduce((s, f) => s + f.deletions, 0),
};
res.json({ files: doneFiles, stats: doneStats });
res.json({
files: [],
stats: {
filesChanged,
additions: (patch.match(/^\+[^+]/gm) || []).length,
deletions: (patch.match(/^-[^-]/gm) || []).length,
},
});
return;
}
@@ -342,6 +473,13 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
if (task.column === "done" && task.mergeDetails?.commitSha) {
const aggregated = await collectDoneTaskFiles(task, scopedStore);
if (aggregated.usedAggregation && aggregated.files.length > 0) {
res.json(aggregated.files.map((file) => ({ path: file.path, status: file.status, diff: file.patch })));
return;
}
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;