diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index b8c4803f6..f46c72fb8 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -48,7 +48,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level - **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces. - **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads. - **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. -- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation. On mobile (≤768px), the viewer switches to a single-pane flow: the file list and diff are shown one at a time with a back button for navigation between them +- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation. On mobile (≤768px), the viewer switches to a single-pane flow: the file list and diff are shown one at a time with a back button for navigation between them. The board card file count and the changed-files viewer always agree — both use the same merge-base diff strategy, so the card never advertises files that the viewer cannot inspect - **GitHub Import**: Import issues directly from GitHub repositories - **PR Management**: Create, monitor, and merge pull requests for in-review tasks diff --git a/packages/dashboard/src/__tests__/routes-file-diffs.test.ts b/packages/dashboard/src/__tests__/routes-file-diffs.test.ts index 799c1a097..29dffcf47 100644 --- a/packages/dashboard/src/__tests__/routes-file-diffs.test.ts +++ b/packages/dashboard/src/__tests__/routes-file-diffs.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import type { Task } from "@fusion/core"; -import * as childProcess from "node:child_process"; -import * as fs from "node:fs"; -import { get } from "../test-request.js"; vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -21,8 +18,8 @@ vi.mock("node:fs", async () => { }; }); -import { createServer } from "../server.js"; - +const childProcess = await import("node:child_process"); +const fs = await import("node:fs"); const mockExecSync = vi.mocked(childProcess.execSync); const mockExistsSync = vi.mocked(fs.existsSync); @@ -33,26 +30,6 @@ class MockStore extends EventEmitter { return process.cwd(); } - 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()); - } - async getTask(id: string): Promise { const task = this.tasks.get(id); if (!task) { @@ -65,6 +42,14 @@ class MockStore extends EventEmitter { addTask(task: Task): void { this.tasks.set(task.id, task); } + + getMissionStore() { + return new EventEmitter(); + } + + async listTasks(): Promise { + return []; + } } function createTask(overrides: Partial = {}): Task { @@ -86,122 +71,397 @@ function createTask(overrides: Partial = {}): Task { }; } -async function requestFileDiffs(app: Parameters[0], taskId = "KB-651"): Promise<{ status: number; body: any }> { - const response = await get(app, `/api/tasks/${taskId}/file-diffs`); - return { status: response.status, body: response.body }; +async function getFileDiffsHandler(store: MockStore) { + vi.resetModules(); + const { createApiRoutes } = await import("../routes.js"); + const router = createApiRoutes(store as any); + const layer = (router as any).stack.find( + (candidate: any) => + candidate.route?.path === "/tasks/:id/file-diffs" && + candidate.route?.methods?.get, + ); + + if (!layer) { + throw new Error("GET /tasks/:id/file-diffs route not found"); + } + + return layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => Promise; +} + +function createMockResponse() { + return { + statusCode: 200, + body: undefined as any, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: any) { + this.body = payload; + return this; + }, + }; +} + +async function requestFileDiffs(store: MockStore, taskId = "KB-651"): Promise<{ status: number; body: any }> { + const handler = await getFileDiffsHandler(store); + return requestFileDiffsWithHandler(handler, taskId); +} + +async function requestFileDiffsWithHandler( + handler: (req: any, res: any) => Promise, + taskId = "KB-651", +): Promise<{ status: number; body: any }> { + const req = { params: { id: taskId } }; + const res = createMockResponse(); + await handler(req, res); + return { status: res.statusCode, body: res.body }; } describe("GET /api/tasks/:id/file-diffs", () => { beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); mockExistsSync.mockReturnValue(true); - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"] }); vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); - it("returns changed files with per-file diffs and supports rename metadata", async () => { + it("uses merge-base to resolve diff base and returns per-file diffs", async () => { const store = new MockStore(); store.addTask(createTask({ baseBranch: "main" })); mockExecSync.mockImplementation((command) => { const cmd = String(command); - if (cmd === "git diff --name-status main...HEAD") { - return "M\tsrc/updated.ts\nA\tsrc/added.ts\nD\tsrc/deleted.ts\nR100\tsrc/old-name.ts\tsrc/new-name.ts\n" as any; + // Merge-base resolution (same strategy as session-files) + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase456\n" as any; } - if (cmd === "git diff main...HEAD -- \"src/updated.ts\"") { + // Committed changes against merge-base + if (cmd === "git diff --name-status mergebase456..HEAD") { + return "M\tsrc/updated.ts\nA\tsrc/added.ts\n" as any; + } + // Working tree changes + if (cmd === "git diff --name-status") { + return "" as any; + } + // Per-file diffs + if (cmd === 'git diff mergebase456..HEAD -- "src/updated.ts"') { return "diff --git a/src/updated.ts b/src/updated.ts\n--- a/src/updated.ts\n+++ b/src/updated.ts\n+hello\n" as any; } - if (cmd === "git diff main...HEAD -- \"src/added.ts\"") { + if (cmd === 'git diff mergebase456..HEAD -- "src/added.ts"') { return "diff --git a/src/added.ts b/src/added.ts\nnew file mode 100644\n+++ b/src/added.ts\n+added\n" as any; } - if (cmd === "git diff main...HEAD -- \"src/deleted.ts\"") { - return "diff --git a/src/deleted.ts b/src/deleted.ts\n--- a/src/deleted.ts\n+++ /dev/null\n-deleted\n" as any; + throw new Error(`Unexpected command: ${cmd}`); + }); + + const response = await requestFileDiffs(store); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toEqual({ + path: "src/updated.ts", + status: "modified", + diff: "diff --git a/src/updated.ts b/src/updated.ts\n--- a/src/updated.ts\n+++ b/src/updated.ts\n+hello\n", + }); + expect(response.body[1]).toEqual({ + path: "src/added.ts", + status: "added", + diff: "diff --git a/src/added.ts b/src/added.ts\nnew file mode 100644\n+++ b/src/added.ts\n+added\n", + }); + }); + + it("supports rename metadata with merge-base strategy", async () => { + const store = new MockStore(); + store.addTask(createTask({ baseBranch: "main" })); + + mockExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase456\n" as any; } - if (cmd === "git diff main...HEAD -- \"src/new-name.ts\"") { + if (cmd === "git diff --name-status mergebase456..HEAD") { + return "R100\tsrc/old-name.ts\tsrc/new-name.ts\n" as any; + } + if (cmd === "git diff --name-status") { + return "" as any; + } + if (cmd === 'git diff mergebase456..HEAD -- "src/new-name.ts"') { return "diff --git a/src/old-name.ts b/src/new-name.ts\nsimilarity index 100%\nrename from src/old-name.ts\nrename to src/new-name.ts\n" as any; } throw new Error(`Unexpected command: ${cmd}`); }); - const app = createServer(store as any); - const response = await requestFileDiffs(app); + const response = await requestFileDiffs(store); expect(response.status).toBe(200); - expect(response.body).toEqual([]); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toEqual({ + path: "src/new-name.ts", + status: "renamed", + diff: "diff --git a/src/old-name.ts b/src/new-name.ts\nsimilarity index 100%\nrename from src/old-name.ts\nrename to src/new-name.ts\n", + oldPath: "src/old-name.ts", + }); + }); + it("falls back to HEAD~1 when merge-base fails", async () => { + const store = new MockStore(); + store.addTask(createTask({ baseBranch: "main" })); + + mockExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + throw new Error("merge-base failed"); + } + if (cmd === "git rev-parse HEAD~1") { + return "parent456\n" as any; + } + if (cmd === "git diff --name-status parent456..HEAD") { + return "M\tsrc/fallback.ts\n" as any; + } + if (cmd === "git diff --name-status") { + return "" as any; + } + if (cmd === 'git diff parent456..HEAD -- "src/fallback.ts"') { + return "diff --git a/src/fallback.ts b/src/fallback.ts\n+fallback\n" as any; + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const response = await requestFileDiffs(store); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toEqual({ + path: "src/fallback.ts", + status: "modified", + diff: "diff --git a/src/fallback.ts b/src/fallback.ts\n+fallback\n", + }); }); it("returns empty array when worktree is missing", async () => { const store = new MockStore(); store.addTask(createTask({ worktree: undefined })); - const app = createServer(store as any); - const response = await requestFileDiffs(app); + const response = await requestFileDiffs(store); expect(response.status).toBe(200); expect(response.body).toEqual([]); expect(mockExecSync).not.toHaveBeenCalled(); - }); - it("falls back to HEAD diff when base branch diff fails", async () => { + it("includes working-tree changes alongside committed changes", async () => { const store = new MockStore(); store.addTask(createTask({ baseBranch: "main" })); mockExecSync.mockImplementation((command) => { const cmd = String(command); - if (cmd === "git diff --name-status main...HEAD") { - throw new Error("bad base branch"); + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase456\n" as any; } - if (cmd === "git diff --name-status HEAD") { - return "M\tsrc/local.ts\n" as any; + if (cmd === "git diff --name-status mergebase456..HEAD") { + return "M\tsrc/committed.ts\n" as any; } - if (cmd === "git diff HEAD -- \"src/local.ts\"") { - return "diff --git a/src/local.ts b/src/local.ts\n+local\n" as any; + // Working tree has a different file + if (cmd === "git diff --name-status") { + return "A\tsrc/uncommitted.ts\n" as any; + } + if (cmd === 'git diff mergebase456..HEAD -- "src/committed.ts"') { + return "diff --git a/src/committed.ts b/src/committed.ts\n+committed\n" as any; + } + if (cmd === 'git diff mergebase456..HEAD -- "src/uncommitted.ts"') { + return "diff --git a/src/uncommitted.ts b/src/uncommitted.ts\n+uncommitted\n" as any; } throw new Error(`Unexpected command: ${cmd}`); }); - const app = createServer(store as any); - const response = await requestFileDiffs(app); + const response = await requestFileDiffs(store); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + + const paths = response.body.map((f: any) => f.path); + expect(paths).toContain("src/committed.ts"); + expect(paths).toContain("src/uncommitted.ts"); + }); + + it("deduplicates files that appear in both committed and working-tree diffs", async () => { + const store = new MockStore(); + store.addTask(createTask({ baseBranch: "main" })); + + mockExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase456\n" as any; + } + // Same file in both committed and working-tree + if (cmd === "git diff --name-status mergebase456..HEAD") { + return "M\tsrc/shared.ts\n" as any; + } + if (cmd === "git diff --name-status") { + return "M\tsrc/shared.ts\n" as any; + } + if (cmd === 'git diff mergebase456..HEAD -- "src/shared.ts"') { + return "diff --git a/src/shared.ts b/src/shared.ts\n+shared\n" as any; + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const response = await requestFileDiffs(store); + + expect(response.status).toBe(200); + // Should deduplicate — only one entry for src/shared.ts + expect(response.body).toHaveLength(1); + expect(response.body[0].path).toBe("src/shared.ts"); + }); + + it("returns empty array when no base ref and no working-tree changes", async () => { + const store = new MockStore(); + store.addTask(createTask({ baseBranch: "main" })); + + mockExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + throw new Error("no merge base"); + } + if (cmd === "git rev-parse HEAD~1") { + throw new Error("no parent"); + } + if (cmd === "git diff --name-status") { + return "" as any; + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const response = await requestFileDiffs(store); expect(response.status).toBe(200); expect(response.body).toEqual([]); - }); it("uses the 10-second cache before recomputing", async () => { const store = new MockStore(); store.addTask(createTask({ baseBranch: "main" })); + let callCount = 0; mockExecSync.mockImplementation((command) => { + callCount++; const cmd = String(command); - if (cmd === "git diff --name-status main...HEAD") { + if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase456\n" as any; + } + if (cmd === "git diff --name-status mergebase456..HEAD") { return "M\tsrc/cached.ts\n" as any; } - if (cmd === "git diff main...HEAD -- \"src/cached.ts\"") { + if (cmd === "git diff --name-status") { + return "" as any; + } + if (cmd === 'git diff mergebase456..HEAD -- "src/cached.ts"') { return "diff --git a/src/cached.ts b/src/cached.ts\n+cached\n" as any; } throw new Error(`Unexpected command: ${cmd}`); }); - const app = createServer(store as any); - const first = await requestFileDiffs(app); - const second = await requestFileDiffs(app); + const handler = await getFileDiffsHandler(store); - expect(first.body).toEqual([]); - expect(second.body).toEqual([]); - expect(mockExecSync).not.toHaveBeenCalled(); + const first = await requestFileDiffsWithHandler(handler); + expect(first.status).toBe(200); + expect(first.body).toHaveLength(1); + expect(first.body[0].path).toBe("src/cached.ts"); + const callsAfterFirst = callCount; + + // Second request within cache window should return cached data + const second = await requestFileDiffsWithHandler(handler); + expect(second.body).toEqual(first.body); + // No additional execSync calls — served from cache + expect(callCount).toBe(callsAfterFirst); + + // Advance past cache TTL vi.advanceTimersByTime(10001); - const third = await requestFileDiffs(app); + const third = await requestFileDiffsWithHandler(handler); + expect(third.body).toHaveLength(1); + // Should have made fresh git calls + expect(callCount).toBeGreaterThan(callsAfterFirst); + }); - expect(third.body).toEqual([]); - expect(mockExecSync).not.toHaveBeenCalled(); + it("agrees with session-files on file list for the same task worktree", async () => { + const store = new MockStore(); + store.addTask(createTask({ baseBranch: "main", id: "KB-AGREE" })); + // Both routes use the same merge-base resolution strategy. + // Set up mocks that exercise the shared merge-base + HEAD~1 fallback path. + const mergeBaseCmd = "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main"; + const committedDiffNameOnly = "git diff --name-only mergebase456..HEAD"; + const committedDiffNameStatus = "git diff --name-status mergebase456..HEAD"; + const workingTreeNameOnly = "git diff --name-only"; + const workingTreeNameStatus = "git diff --name-status"; + + mockExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd === mergeBaseCmd) { + return "mergebase456\n" as any; + } + // session-files uses --name-only + if (cmd === committedDiffNameOnly) { + return "src/a.ts\nsrc/b.ts\n" as any; + } + if (cmd === workingTreeNameOnly) { + return "src/c.ts\n" as any; + } + // file-diffs uses --name-status + if (cmd === committedDiffNameStatus) { + return "M\tsrc/a.ts\nA\tsrc/b.ts\n" as any; + } + if (cmd === workingTreeNameStatus) { + return "M\tsrc/c.ts\n" as any; + } + // Per-file diffs for file-diffs + if (cmd.includes('git diff mergebase456..HEAD -- "src/a.ts"')) { + return "diff a" as any; + } + if (cmd.includes('git diff mergebase456..HEAD -- "src/b.ts"')) { + return "diff b" as any; + } + if (cmd.includes('git diff mergebase456..HEAD -- "src/c.ts"')) { + return "diff c" as any; + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + // Request session-files (card count) + const sessionHandler = await import("../routes.js").then(({ createApiRoutes }) => { + const router = createApiRoutes(store as any); + const layer = (router as any).stack.find( + (candidate: any) => + candidate.route?.path === "/tasks/:id/session-files" && + candidate.route?.methods?.get, + ); + return layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => Promise; + }); + + const sessionReq = { params: { id: "KB-AGREE" } }; + const sessionRes = createMockResponse(); + await sessionHandler(sessionReq, sessionRes); + + expect(sessionRes.statusCode).toBe(200); + const sessionFiles: string[] = sessionRes.body as string[]; + expect(sessionFiles).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); + + // Request file-diffs (modal viewer) + const diffsHandler = await getFileDiffsHandler(store); + const diffsRes = await requestFileDiffsWithHandler(diffsHandler, "KB-AGREE"); + + expect(diffsRes.status).toBe(200); + const diffFiles = diffsRes.body as Array<{ path: string }>; + const diffPaths = diffFiles.map((f) => f.path); + + // Both endpoints must report the same set of files + expect(diffPaths.sort()).toEqual(sessionFiles.sort()); }); }); diff --git a/packages/dashboard/src/__tests__/routes-session-files.test.ts b/packages/dashboard/src/__tests__/routes-session-files.test.ts index 952dfebf2..e75739d6e 100644 --- a/packages/dashboard/src/__tests__/routes-session-files.test.ts +++ b/packages/dashboard/src/__tests__/routes-session-files.test.ts @@ -281,6 +281,28 @@ describe("GET /api/tasks/:id/session-files", () => { expect(mockExecSync).not.toHaveBeenCalled(); }); + it("returns empty array when there are no committed or working-tree changes", async () => { + const store = new MockStore(); + store.addTask(createTask({ id: "FN-675-empty", baseCommitSha: undefined })); + mockExecSync.mockImplementation((command) => { + if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") { + return "mergebase123\n" as any; + } + if (String(command) === "git diff --name-only mergebase123..HEAD") { + return "" as any; + } + if (String(command) === "git diff --name-only") { + return "" as any; + } + throw new Error(`Unexpected command: ${String(command)}`); + }); + + const response = await requestSessionFiles(store, "FN-675-empty"); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + it("uses the 10-second cache before recomputing", async () => { const store = new MockStore(); store.addTask(createTask({ id: "FN-675-cache", baseCommitSha: "cachebase" })); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 96ee34f04..97efb6bb1 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -6770,13 +6770,15 @@ Output ONLY the prompt text (no markdown, no explanations).`; /** * GET /api/tasks/:id/file-diffs * Fetch changed files with individual git diffs for a task worktree. + * Uses the same merge-base resolution strategy as the session-files route + * so the board card count and the changed-files viewer always agree. * Returns: Array<{ path, status, diff, oldPath? }> */ router.get("/tasks/:id/file-diffs", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); - if (!task.worktree || !existsSync(task.worktree)) { + if (!task.worktree || !nodeFs.existsSync(task.worktree)) { res.json([]); return; } @@ -6789,56 +6791,99 @@ Output ONLY the prompt text (no markdown, no explanations).`; const baseBranch = task.baseBranch ?? "main"; const cwd = task.worktree; - let filesOutput = ""; - let diffBase = `${baseBranch}...HEAD`; + + // Resolve a diff base using the same merge-base strategy as session-files + // so both endpoints always agree on which files have changed. + let diffBase: string | undefined; try { - filesOutput = execSync(`git diff --name-status ${diffBase}`, { - cwd, - encoding: "utf-8", - timeout: 5000, - }).trim(); + diffBase = nodeChildProcess.execSync( + `git merge-base HEAD origin/${baseBranch} 2>/dev/null || git merge-base HEAD ${baseBranch}`, + { cwd, encoding: "utf-8", timeout: 5000 }, + ).trim(); } catch { - diffBase = "HEAD"; - filesOutput = execSync("git diff --name-status HEAD", { - cwd, - encoding: "utf-8", - timeout: 5000, - }).trim(); + try { + diffBase = nodeChildProcess.execSync("git rev-parse HEAD~1", { + cwd, + encoding: "utf-8", + timeout: 5000, + }).trim(); + } catch { + diffBase = undefined; + } } - const files = filesOutput - ? filesOutput.split("\n").filter(Boolean).map((line) => { + // Collect file statuses from both committed changes (against diffBase) + // and working-tree changes, deduplicating by path to match session-files. + const fileMap = new Map(); + + if (diffBase) { + try { + const committedOutput = nodeChildProcess.execSync( + `git diff --name-status ${diffBase}..HEAD`, + { cwd, encoding: "utf-8", timeout: 5000 }, + ).trim(); + for (const line of committedOutput.split("\n").filter(Boolean)) { const parts = line.split("\t"); const statusCode = parts[0] ?? "M"; - let status: "added" | "modified" | "deleted" | "renamed" = "modified"; - let path = parts[1] ?? ""; - let oldPath: string | undefined; - - if (statusCode.startsWith("A")) { - status = "added"; - } else if (statusCode.startsWith("D")) { - status = "deleted"; - } else if (statusCode.startsWith("R")) { - status = "renamed"; - oldPath = parts[1]; - path = parts[2] ?? parts[1] ?? ""; + if (statusCode.startsWith("R")) { + fileMap.set(parts[2] ?? parts[1] ?? "", { statusCode, oldPath: parts[1] }); + } else { + fileMap.set(parts[1] ?? "", { statusCode }); } + } + } catch { + // committed diff failed — continue with working-tree only + } + } - let diff = ""; - try { - diff = execSync(`git diff ${diffBase} -- "${path}"`, { - cwd, - encoding: "utf-8", - timeout: 5000, - }); - } catch { - diff = ""; - } + try { + const workingTreeOutput = nodeChildProcess.execSync("git diff --name-status", { + cwd, + encoding: "utf-8", + timeout: 5000, + }).trim(); + for (const line of workingTreeOutput.split("\n").filter(Boolean)) { + const parts = line.split("\t"); + const statusCode = parts[0] ?? "M"; + if (statusCode.startsWith("R")) { + fileMap.set(parts[2] ?? parts[1] ?? "", { statusCode, oldPath: parts[1] }); + } else { + fileMap.set(parts[1] ?? "", { statusCode }); + } + } + } catch { + // working tree diff failed — continue with committed only + } - return oldPath ? { path, status, diff, oldPath } : { path, status, diff }; - }) - : []; + // Build the result array with per-file diffs using the two-dot range + // against the resolved merge-base. + const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD"; + + const files = Array.from(fileMap.entries()).map(([filePath, { statusCode, oldPath }]) => { + let status: "added" | "modified" | "deleted" | "renamed" = "modified"; + + if (statusCode.startsWith("A")) { + status = "added"; + } else if (statusCode.startsWith("D")) { + status = "deleted"; + } else if (statusCode.startsWith("R")) { + status = "renamed"; + } + + let diff = ""; + try { + diff = nodeChildProcess.execSync(`git diff ${diffRange} -- "${filePath}"`, { + cwd, + encoding: "utf-8", + timeout: 5000, + }); + } catch { + diff = ""; + } + + return oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff }; + }); fileDiffsCache.set(task.id, { files,