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:
@@ -1,7 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { Task } from "@fusion/core";
|
import type { Task, TaskCommitAssociation } from "@fusion/core";
|
||||||
import * as fs from "node:fs";
|
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";
|
import { createServer } from "../server.js";
|
||||||
|
|
||||||
vi.mock("node:fs", async () => {
|
vi.mock("node:fs", async () => {
|
||||||
@@ -16,6 +24,7 @@ const mockExistsSync = vi.mocked(fs.existsSync);
|
|||||||
|
|
||||||
class MockStore extends EventEmitter {
|
class MockStore extends EventEmitter {
|
||||||
private tasks = new Map<string, Task>();
|
private tasks = new Map<string, Task>();
|
||||||
|
private associations = new Map<string, TaskCommitAssociation[]>();
|
||||||
|
|
||||||
getRootDir(): string {
|
getRootDir(): string {
|
||||||
return "/tmp/fn-679";
|
return "/tmp/fn-679";
|
||||||
@@ -59,6 +68,14 @@ class MockStore extends EventEmitter {
|
|||||||
addTask(task: Task): void {
|
addTask(task: Task): void {
|
||||||
this.tasks.set(task.id, task);
|
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 {
|
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 { get } = await import("../test-request.js");
|
||||||
const url = `/api/tasks/${taskId}/diff${worktree ? `?worktree=${encodeURIComponent(worktree)}` : ""}`;
|
return get(app, `/api/tasks/${taskId}/diff`);
|
||||||
return get(app, url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockExistsSync.mockReturnValue(true);
|
mockExistsSync.mockReturnValue(true);
|
||||||
@@ -96,173 +140,118 @@ describe("GET /api/tasks/:id/diff", () => {
|
|||||||
vi.restoreAllMocks();
|
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();
|
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 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);
|
const fileDiffsResponse = await requestFileDiffs(app);
|
||||||
}, 15_000);
|
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();
|
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 app = createServer(store as any);
|
||||||
const response = await requestDiff(app);
|
const response = await requestDiff(app);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
// Should return 200 or 500 depending on git command results
|
expect(response.body.stats.filesChanged).toBe(1);
|
||||||
expect([200, 500]).toContain(response.status);
|
expect(response.body.files).toHaveLength(1);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/tasks/:id/diff — done tasks", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockExistsSync.mockReturnValue(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("falls back to merge commit range when lineage associations are empty", async () => {
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty result when rev-parse sha^ fails", async () => {
|
|
||||||
const store = new MockStore();
|
const store = new MockStore();
|
||||||
store.addTask(createTask({
|
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "m1" } }));
|
||||||
column: "done",
|
store.setAssociations("lin-1", []);
|
||||||
mergeDetails: { commitSha: "broken_sha" },
|
|
||||||
}));
|
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 app = createServer(store as any);
|
||||||
const response = await requestDiff(app);
|
const response = await requestDiff(app);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.files).toEqual([]);
|
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();
|
const store = new MockStore();
|
||||||
store.addTask(createTask({
|
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "good" } }));
|
||||||
column: "done",
|
store.setAssociations("lin-1", [makeAssociation("bad", "2026-04-01T00:00:00.000Z"), makeAssociation("good", "2026-04-01T00:01:00.000Z")]);
|
||||||
mergeDetails: undefined,
|
|
||||||
}));
|
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 app = createServer(store as any);
|
||||||
const response = await requestDiff(app);
|
const response = await requestDiff(app);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.files).toEqual([]);
|
expect(response.body.stats.filesChanged).toBe(1);
|
||||||
});
|
expect(response.body.files[0].path).toBe("reachable.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree", () => {
|
it("includes mergeDetails.commitSha even when missing from associations", async () => {
|
||||||
beforeEach(() => {
|
const store = new MockStore();
|
||||||
vi.clearAllMocks();
|
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "merge-only" } }));
|
||||||
mockExistsSync.mockReturnValue(true);
|
store.setAssociations("lin-1", [makeAssociation("assoc-1", "2026-04-01T00:00:00.000Z")]);
|
||||||
});
|
|
||||||
|
runGitCommandMock.mockImplementation(async (args: string[]) => {
|
||||||
afterEach(() => {
|
const key = args.join(" ");
|
||||||
vi.restoreAllMocks();
|
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";
|
||||||
it("returns empty diff when task.worktree is null and no worktree query param", async () => {
|
if (key === "diff --name-status p..merge-only") return "A\tmerged.txt";
|
||||||
const store = new MockStore();
|
if (key === "diff p..merge-only -- merged.txt") return "+ok\n";
|
||||||
store.addTask(createTask({
|
throw new Error(`Unexpected git command: ${key}`);
|
||||||
column: "in-progress",
|
});
|
||||||
worktree: null as any,
|
|
||||||
}));
|
const app = createServer(store as any);
|
||||||
|
const response = await requestDiff(app);
|
||||||
const app = createServer(store as any);
|
expect(response.status).toBe(200);
|
||||||
const response = await requestDiff(app);
|
expect(response.body.stats.filesChanged).toBe(1);
|
||||||
|
expect(response.body.files[0].path).toBe("merged.txt");
|
||||||
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 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
* 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) {
|
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 rootDir = scopedStore.getRootDir();
|
||||||
const sha = task.mergeDetails.commitSha;
|
const sha = task.mergeDetails.commitSha;
|
||||||
|
|
||||||
let mergeBase: string | undefined;
|
let mergeBase: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
|
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -162,45 +319,19 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
|
|||||||
return;
|
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<{
|
res.json({
|
||||||
path: string;
|
files: [],
|
||||||
status: "added" | "modified" | "deleted";
|
stats: {
|
||||||
additions: number;
|
filesChanged,
|
||||||
deletions: number;
|
additions: (patch.match(/^\+[^+]/gm) || []).length,
|
||||||
patch: string;
|
deletions: (patch.match(/^-[^-]/gm) || []).length,
|
||||||
}> = [];
|
},
|
||||||
|
});
|
||||||
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 });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +473,13 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (task.column === "done" && task.mergeDetails?.commitSha) {
|
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 rootDir = scopedStore.getRootDir();
|
||||||
const sha = task.mergeDetails.commitSha;
|
const sha = task.mergeDetails.commitSha;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user