import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import type { Task, TaskCommitAssociation } from "@fusion/core"; import * as fs from "node:fs"; const runGitCommandMock = vi.fn<(...args: any[]) => Promise>(); 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 () => { const actual = await vi.importActual("node:fs"); return { ...actual, existsSync: vi.fn(), }; }); const mockExistsSync = vi.mocked(fs.existsSync); class MockStore extends EventEmitter { private tasks = new Map(); private associations = new Map(); getRootDir(): string { return "/tmp/fn-679"; } getFusionDir(): string { return "/tmp/fn-679/.fusion"; } getDatabase() { return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }), }; } getMissionStore() { return { listMissions: vi.fn().mockResolvedValue([]), createMission: vi.fn(), getMission: vi.fn(), updateMission: vi.fn(), deleteMission: vi.fn(), listTemplates: vi.fn().mockResolvedValue([]), createTemplate: vi.fn(), getTemplate: vi.fn(), updateTemplate: vi.fn(), deleteTemplate: vi.fn(), instantiateMission: vi.fn(), }; } async listTasks(): Promise { return Array.from(this.tasks.values()); } getTask(id: string): Task | undefined { return this.tasks.get(id); } 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 { return this.associations.get(lineageId) ?? []; } } function createTask(overrides: Partial = {}): Task { return { id: "FN-679", title: "Test task", description: "Test description", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", columnMovedAt: "2026-04-01T00:00:00.000Z", worktree: "/tmp/fn-679", baseBranch: "main", ...overrides, }; } async function requestDiff(app: Parameters[0], taskId = "FN-679"): Promise<{ status: number; body: any }> { const { get } = await import("../test-request.js"); return get(app, `/api/tasks/${taskId}/diff`); } async function requestFileDiffs(app: Parameters[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) { 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); }); afterEach(() => { vi.restoreAllMocks(); }); 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-list --parents -n 1 c1": "c1 p1", "diff --name-status -M p1..c1": "A\ta.txt\nM\tb.txt", "diff -M p1..c1 -- a.txt": "+a\n", "diff -M p1..c1 -- b.txt": "+b\n", "rev-list --parents -n 1 c2": "c2 p2", "diff --name-status -M p2..c2": "M\tb.txt\nA\tc.txt", "diff -M p2..c2 -- b.txt": "+bb\n-b\n", "diff -M p2..c2 -- c.txt": "+c\n", "rev-list --parents -n 1 c3": "c3 p3", "diff --name-status -M p3..c3": "A\td.txt", "diff -M p3..c3 -- d.txt": "+d\n", "rev-parse c1^": "p1", "diff --name-status -M p1..c3": "A\ta.txt\nM\tb.txt\nA\tc.txt\nA\td.txt", "diff -M p1..c3 -- a.txt": "+a\n", "diff -M p1..c3 -- b.txt": "+bb\n-b\n", "diff -M p1..c3 -- c.txt": "+c\n", "diff -M p1..c3 -- d.txt": "+d\n", }); const app = createServer(store as any); 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"]); 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("single-commit lineage matches existing behavior", async () => { const store = new MockStore(); 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-list --parents -n 1 c1": "c1 p1", "diff --name-status -M p1..c1": "A\tone.txt", "diff -M p1..c1 -- one.txt": "+one\n", }); 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).toHaveLength(1); }); it("falls back to merge commit range when lineage associations are empty", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "m1" } })); store.setAssociations("lin-1", []); gitResponses({ "merge-base --is-ancestor m1 HEAD": "", "rev-list --parents -n 1 m1": "m1 pm1", "diff --name-status -M pm1..m1": "M\tx.txt", "diff -M pm1..m1 -- x.txt": "+x\n-y\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("x.txt"); expect(response.body.stats.filesChanged).toBe(1); }); it("skips unreachable lineage SHAs and still aggregates reachable commits", async () => { const store = new MockStore(); 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-list --parents -n 1 good") return "good p"; if (key === "diff --name-status -M p..good") return "A\treachable.txt"; if (key === "diff -M 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.stats.filesChanged).toBe(1); expect(response.body.files[0].path).toBe("reachable.txt"); }); it("aggregates revised done tasks that gained additional lineage commits", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "rev-2" } })); store.setAssociations("lin-1", [makeAssociation("rev-1", "2026-04-01T00:00:00.000Z"), makeAssociation("rev-2", "2026-04-02T00:00:00.000Z")]); gitResponses({ "merge-base --is-ancestor rev-1 HEAD": "", "merge-base --is-ancestor rev-2 HEAD": "", "rev-list --parents -n 1 rev-1": "rev-1 p1", "diff --name-status -M p1..rev-1": "A\tinitial.ts", "diff -M p1..rev-1 -- initial.ts": "+i\n", "rev-list --parents -n 1 rev-2": "rev-2 p2", "diff --name-status -M p2..rev-2": "A\trevision.ts", "diff -M p2..rev-2 -- revision.ts": "+r\n", "rev-parse rev-1^": "p1", "diff --name-status -M p1..rev-2": "A\tinitial.ts\nA\trevision.ts", "diff -M p1..rev-2 -- initial.ts": "+i\n", "diff -M p1..rev-2 -- revision.ts": "+r\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.stats.filesChanged).toBe(2); expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["initial.ts", "revision.ts"]); }); it("uses legacy single-commit behavior when only mergeDetails.commitSha exists", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "healed" } })); gitResponses({ "merge-base --is-ancestor healed HEAD": "", "rev-list --parents -n 1 healed": "healed ph", "diff --name-status -M ph..healed": "A\thealed.ts", "diff -M ph..healed -- healed.ts": "+h\n", }); 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("healed.ts"); }); it("uses rebaseBaseSha..commitSha fallback range for done task diff", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "head-sha", rebaseBaseSha: "base-sha", filesChanged: 2 } })); gitResponses({ "merge-base --is-ancestor head-sha HEAD": "", "merge-base --is-ancestor base-sha head-sha": "", "diff --name-status -M base-sha..head-sha": "A\tone.ts\nA\ttwo.ts", "diff -M base-sha..head-sha -- one.ts": "+1\n", "diff -M base-sha..head-sha -- two.ts": "+2\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.stats.filesChanged).toBe(2); expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["one.ts", "two.ts"]); }); it("FN-4741: prefers rebaseBaseSha..commitSha over single-commit aggregate even when mergeDetails.filesChanged is absent", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "tip-sha", rebaseBaseSha: "base-sha" } })); gitResponses({ "merge-base --is-ancestor tip-sha HEAD": "", "rev-list --parents -n 1 tip-sha": "tip-sha parent-sha", "diff --name-status -M parent-sha..tip-sha": "M\tonly-tip.ts", "diff -M parent-sha..tip-sha -- only-tip.ts": "+tip\n", "merge-base --is-ancestor base-sha tip-sha": "", "diff --name-status -M base-sha..tip-sha": "A\tfile-a.ts\nM\tfile-b.ts\nM\tonly-tip.ts", "diff -M base-sha..tip-sha -- file-a.ts": "+a\n", "diff -M base-sha..tip-sha -- file-b.ts": "+b\n", "diff -M base-sha..tip-sha -- only-tip.ts": "+tip\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.stats.filesChanged).toBe(3); expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["file-a.ts", "file-b.ts", "only-tip.ts"]); }); it("FN-4726: prefers rebaseBaseSha..commitSha range over partial lineage aggregate for multi-commit rebase done task", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "tip-sha", rebaseBaseSha: "base-sha", filesChanged: 4 }, })); store.setAssociations("lin-1", [makeAssociation("tip-sha", "2026-04-01T00:02:00.000Z")]); gitResponses({ "merge-base --is-ancestor tip-sha HEAD": "", "rev-list --parents -n 1 tip-sha": "tip-sha parent-sha", "diff --name-status -M parent-sha..tip-sha": "M\tonly-tip.ts", "diff -M parent-sha..tip-sha -- only-tip.ts": "+tip\n", "merge-base --is-ancestor base-sha tip-sha": "", "diff --name-status -M base-sha..tip-sha": "A\tfile-a.ts\nM\tfile-b.ts\nM\tfile-c.ts\nM\tonly-tip.ts", "diff -M base-sha..tip-sha -- file-a.ts": "+a\n", "diff -M base-sha..tip-sha -- file-b.ts": "+b\n", "diff -M base-sha..tip-sha -- file-c.ts": "+c\n", "diff -M base-sha..tip-sha -- only-tip.ts": "+tip\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.stats.filesChanged).toBe(4); expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["file-a.ts", "file-b.ts", "file-c.ts", "only-tip.ts"]); }); it("FN-4726: prefers rebaseBaseSha..commitSha range over partial lineage aggregate for done task file-diffs", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "tip-sha", rebaseBaseSha: "base-sha", filesChanged: 4 }, })); store.setAssociations("lin-1", [makeAssociation("tip-sha", "2026-04-01T00:02:00.000Z")]); gitResponses({ "merge-base --is-ancestor tip-sha HEAD": "", "rev-list --parents -n 1 tip-sha": "tip-sha parent-sha", "diff --name-status -M parent-sha..tip-sha": "M\tonly-tip.ts", "diff -M parent-sha..tip-sha -- only-tip.ts": "+tip\n", "merge-base --is-ancestor base-sha tip-sha": "", "diff --name-status -M base-sha..tip-sha": "A\tfile-a.ts\nM\tfile-b.ts\nM\tfile-c.ts\nM\tonly-tip.ts", "diff -M base-sha..tip-sha -- file-a.ts": "+a\n", "diff -M base-sha..tip-sha -- file-b.ts": "+b\n", "diff -M base-sha..tip-sha -- file-c.ts": "+c\n", "diff -M base-sha..tip-sha -- only-tip.ts": "+tip\n", }); const app = createServer(store as any); const response = await requestFileDiffs(app); expect(response.status).toBe(200); expect(response.body).toHaveLength(4); expect(response.body.map((f: any) => f.path).sort()).toEqual(["file-a.ts", "file-b.ts", "file-c.ts", "only-tip.ts"]); }); it("FN-4726: falls back to partial lineage aggregate when rebaseBaseSha is not an ancestor and aggregation is incomplete", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "tip-sha", rebaseBaseSha: "base-sha", filesChanged: 4 }, })); store.setAssociations("lin-1", [makeAssociation("tip-sha", "2026-04-01T00:02:00.000Z")]); runGitCommandMock.mockImplementation(async (args: string[]) => { const key = args.join(" "); if (key === "merge-base --is-ancestor tip-sha HEAD") return ""; if (key === "rev-list --parents -n 1 tip-sha") return "tip-sha parent-sha"; if (key === "diff --name-status -M parent-sha..tip-sha") return "M\tonly-tip.ts"; if (key === "diff -M parent-sha..tip-sha -- only-tip.ts") return "+tip\n"; if (key === "merge-base --is-ancestor base-sha tip-sha") throw new Error("unreachable"); throw new Error(`Unexpected git command: ${key}`); }); const app = createServer(store as any); const diffResponse = await requestDiff(app); expect(diffResponse.status).toBe(200); expect(diffResponse.body.stats.filesChanged).toBe(1); expect(diffResponse.body.files.map((f: any) => f.path)).toEqual(["only-tip.ts"]); const fileDiffResponse = await requestFileDiffs(app); expect(fileDiffResponse.status).toBe(200); expect(fileDiffResponse.body).toHaveLength(1); expect(fileDiffResponse.body[0].path).toBe("only-tip.ts"); }); it("falls back to single-commit range when rebaseBaseSha is unreachable", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "head-sha", rebaseBaseSha: "base-sha" } })); runGitCommandMock.mockImplementation(async (args: string[]) => { const key = args.join(" "); if (key === "merge-base --is-ancestor head-sha HEAD") return ""; if (key === "merge-base --is-ancestor base-sha head-sha") throw new Error("unreachable"); if (key === "rev-list --parents -n 1 head-sha") return "head-sha parent"; if (key === "diff --name-status -M parent..head-sha") return "A\tfallback.ts"; if (key === "diff -M parent..head-sha -- fallback.ts") return "+f\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).toHaveLength(1); expect(response.body.files[0].path).toBe("fallback.ts"); }); it("uses rebaseBaseSha..commitSha fallback range for done task file-diffs", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "head-sha", rebaseBaseSha: "base-sha", filesChanged: 2 } })); gitResponses({ "merge-base --is-ancestor head-sha HEAD": "", "merge-base --is-ancestor base-sha head-sha": "", "diff --name-status -M base-sha..head-sha": "A\tone.ts\nA\ttwo.ts", "diff -M base-sha..head-sha -- one.ts": "+1\n", "diff -M base-sha..head-sha -- two.ts": "+2\n", }); const app = createServer(store as any); const response = await requestFileDiffs(app); expect(response.status).toBe(200); expect(response.body).toHaveLength(2); expect(response.body.map((f: any) => f.path).sort()).toEqual(["one.ts", "two.ts"]); }); it("FN-4741: prefers rebaseBaseSha..commitSha for file-diffs even when mergeDetails.filesChanged is absent", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "tip-sha", rebaseBaseSha: "base-sha" } })); gitResponses({ "merge-base --is-ancestor tip-sha HEAD": "", "rev-list --parents -n 1 tip-sha": "tip-sha parent-sha", "diff --name-status -M parent-sha..tip-sha": "M\tonly-tip.ts", "diff -M parent-sha..tip-sha -- only-tip.ts": "+tip\n", "merge-base --is-ancestor base-sha tip-sha": "", "diff --name-status -M base-sha..tip-sha": "A\tfile-a.ts\nM\tfile-b.ts\nM\tonly-tip.ts", "diff -M base-sha..tip-sha -- file-a.ts": "+a\n", "diff -M base-sha..tip-sha -- file-b.ts": "+b\n", "diff -M base-sha..tip-sha -- only-tip.ts": "+tip\n", }); const app = createServer(store as any); const response = await requestFileDiffs(app); expect(response.status).toBe(200); expect(response.body).toHaveLength(3); expect(response.body.map((f: any) => f.path).sort()).toEqual(["file-a.ts", "file-b.ts", "only-tip.ts"]); }); it("uses parent-to-parent range for merge commits", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "merge-sha" } })); gitResponses({ "merge-base --is-ancestor merge-sha HEAD": "", "rev-list --parents -n 1 merge-sha": "merge-sha p1 p2", "diff --name-status -M merge-sha^1...merge-sha^2": "A\tfeature-a.ts\nM\tfeature-b.ts", "diff -M merge-sha^1...merge-sha^2 -- feature-a.ts": "+a\n", "diff -M merge-sha^1...merge-sha^2 -- feature-b.ts": "+b\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["feature-a.ts", "feature-b.ts"]); expect(response.body.stats.filesChanged).toBe(2); }); it("keeps lineage aggregation when mergeDetails filesChanged is higher", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "merge", filesChanged: 3 } })); store.setAssociations("lin-1", [makeAssociation("assoc", "2026-04-01T00:00:00.000Z")]); let mergeNameStatusCalls = 0; runGitCommandMock.mockImplementation(async (args: string[]) => { const key = args.join(" "); if (key === "merge-base --is-ancestor assoc HEAD") return ""; if (key === "merge-base --is-ancestor merge HEAD") return ""; if (key === "rev-list --parents -n 1 assoc") return "assoc pa"; if (key === "diff --name-status -M pa..assoc") return "M\ta.txt"; if (key === "diff -M pa..assoc -- a.txt") return "+a\n"; if (key === "rev-list --parents -n 1 merge") return "merge pm"; if (key === "diff --name-status -M pm..merge") { mergeNameStatusCalls += 1; return mergeNameStatusCalls === 1 ? "M\tb.txt" : "A\tone.ts\nA\ttwo.ts\nA\tthree.ts"; } if (key === "diff -M pm..merge -- b.txt") return "+b\n"; if (key === "rev-parse assoc^") return "pa"; if (key === "diff --name-status -M pa..merge") return "M\ta.txt"; if (key === "diff -M pa..merge -- a.txt") return "+a\n"; if (key === "diff -M pm..merge -- one.ts") return "+1\n"; if (key === "diff -M pm..merge -- two.ts") return "+2\n"; if (key === "diff -M pm..merge -- three.ts") return "+3\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.map((f: any) => f.path).sort()).toEqual(["a.txt", "b.txt"]); expect(response.body.files.length).toBe(response.body.stats.filesChanged); }); it("enumerates done commitSha even when commit is unreachable from HEAD", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "orphaned", filesChanged: 2 } })); runGitCommandMock.mockImplementation(async (args: string[]) => { const key = args.join(" "); if (key === "merge-base --is-ancestor orphaned HEAD") throw new Error("unreachable"); if (key === "rev-list --parents -n 1 orphaned") return "orphaned porphan"; if (key === "diff --name-status -M porphan..orphaned") return "A\tone.ts\nA\ttwo.ts"; if (key === "diff -M porphan..orphaned -- one.ts") return "+1\n"; if (key === "diff -M porphan..orphaned -- two.ts") return "+2\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.map((f: any) => f.path).sort()).toEqual(["one.ts", "two.ts"]); expect(response.body.files.length).toBe(response.body.stats.filesChanged); }); it("uses empty tree fallback for root commit done tasks", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "root" } })); gitResponses({ "merge-base --is-ancestor root HEAD": "", "rev-list --parents -n 1 root": "root", "diff --name-status -M 4b825dc642cb6eb9a060e54bf8d69288fbee4904..root": "A\tinitial.ts", "diff -M 4b825dc642cb6eb9a060e54bf8d69288fbee4904..root -- initial.ts": "+init\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files[0].path).toBe("initial.ts"); expect(response.body.files.length).toBe(response.body.stats.filesChanged); }); it("uses renamed path when done task includes rename status", async () => { const store = new MockStore(); store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "rename-sha" } })); gitResponses({ "merge-base --is-ancestor rename-sha HEAD": "", "rev-list --parents -n 1 rename-sha": "rename-sha p0", "diff --name-status -M p0..rename-sha": "R100\tfoo.ts\tbar.ts", "diff -M p0..rename-sha -- bar.ts": "rename from foo.ts\nrename to bar.ts\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("bar.ts"); expect(response.body.files[0].patch.length).toBeGreaterThan(0); expect(response.body.files.length).toBe(response.body.stats.filesChanged); }); it("returns destination path for renames via branch-ref fallback", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-progress", worktree: undefined, branch: "feature/rename", baseBranch: "main" })); gitResponses({ "rev-parse --verify --quiet feature/rename": "rename-sha", "diff --name-status -M origin/main..feature/rename": "R100\told.ts\tnew.ts", "diff origin/main..feature/rename -- new.ts": "rename from old.ts\nrename to new.ts\n+added\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("new.ts"); expect(response.body.files[0].status).toBe("modified"); expect(response.body.stats.filesChanged).toBe(1); }); it("returns destination path for copies via branch-ref fallback", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-review", worktree: undefined, branch: "feature/copy", baseBranch: "main" })); gitResponses({ "rev-parse --verify --quiet feature/copy": "copy-sha", "diff --name-status -M origin/main..feature/copy": "C100\tsrc.ts\tdst.ts", "diff origin/main..feature/copy -- dst.ts": "+copied\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("dst.ts"); expect(response.body.stats.filesChanged).toBe(1); }); it("returns renamed destination and oldPath for /file-diffs branch-ref fallback", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-review", worktree: undefined, branch: "feature/rename-file-diffs", baseBranch: "main" })); gitResponses({ "rev-parse --verify --quiet feature/rename-file-diffs": "rename-sha", "diff --name-status -M origin/main..feature/rename-file-diffs": "R100\told.ts\tnew.ts", "diff origin/main..feature/rename-file-diffs -- new.ts": "rename from old.ts\nrename to new.ts\n", }); const app = createServer(store as any); const response = await requestFileDiffs(app); expect(response.status).toBe(200); expect(response.body).toHaveLength(1); expect(response.body[0]).toMatchObject({ path: "new.ts", status: "renamed", oldPath: "old.ts" }); }); it("returns copied destination path as modified for /file-diffs branch-ref fallback", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-progress", worktree: undefined, branch: "feature/copy-file-diffs", baseBranch: "main" })); gitResponses({ "rev-parse --verify --quiet feature/copy-file-diffs": "copy-sha", "diff --name-status -M origin/main..feature/copy-file-diffs": "C100\tsrc.ts\tdst.ts", "diff origin/main..feature/copy-file-diffs -- dst.ts": "+copied\n", }); const app = createServer(store as any); const response = await requestFileDiffs(app); expect(response.status).toBe(200); expect(response.body).toHaveLength(1); expect(response.body[0]).toMatchObject({ path: "dst.ts", status: "modified" }); }); it("uses diffBase-to-worktree patching for in-progress committed/staged/unstaged files", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-progress", worktree: process.cwd() })); gitResponses({ "diff --name-status -M origin/main..HEAD": "M\tcommitted.ts", "diff --cached --name-status -M": "A\tstaged.ts", "diff --name-status -M": "M\tunstaged.ts", "diff origin/main -- committed.ts": "+c\n", "diff origin/main -- staged.ts": "+s\n", "diff origin/main -- unstaged.ts": "+u\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(3); for (const file of response.body.files) { expect(file.patch.length).toBeGreaterThan(0); expect(file.additions + file.deletions).toBeGreaterThan(0); } expect(response.body.files.length).toBe(response.body.stats.filesChanged); }); it("uses destination path for committed rename in active worktree /diff", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-progress", worktree: process.cwd() })); gitResponses({ "diff --name-status -M origin/main..HEAD": "R100\told.ts\tnew.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff origin/main -- new.ts": "rename from old.ts\nrename to new.ts\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("new.ts"); expect(response.body.stats.filesChanged).toBe(1); }); it("uses destination path for committed copy in active worktree /diff", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-review", worktree: process.cwd() })); gitResponses({ "diff --name-status -M origin/main..HEAD": "C100\tsrc.ts\tdst.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff origin/main -- dst.ts": "+copied\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("dst.ts"); expect(response.body.stats.filesChanged).toBe(1); }); it("uses destination path for staged rename in active worktree /diff", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-progress", worktree: process.cwd() })); gitResponses({ "diff --name-status -M origin/main..HEAD": "", "diff --cached --name-status -M": "R100\told-staged.ts\tnew-staged.ts", "diff --name-status -M": "", "diff origin/main -- new-staged.ts": "rename from old-staged.ts\nrename to new-staged.ts\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("new-staged.ts"); expect(response.body.stats.filesChanged).toBe(1); }); it("uses destination path for unstaged rename in active worktree /diff", async () => { const store = new MockStore(); store.addTask(createTask({ column: "in-review", worktree: process.cwd() })); gitResponses({ "diff --name-status -M origin/main..HEAD": "", "diff --cached --name-status -M": "", "diff --name-status -M": "R100\told-unstaged.ts\tnew-unstaged.ts", "diff origin/main -- new-unstaged.ts": "rename from old-unstaged.ts\nrename to new-unstaged.ts\n", }); const app = createServer(store as any); const response = await requestDiff(app); expect(response.status).toBe(200); expect(response.body.files).toHaveLength(1); expect(response.body.files[0].path).toBe("new-unstaged.ts"); expect(response.body.stats.filesChanged).toBe(1); }); 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-list --parents -n 1 merge-only") return "merge-only p"; if (key === "diff --name-status -M p..merge-only") return "A\tmerged.txt"; if (key === "diff -M 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"); }); });