feat(FN-799): align file-diffs route with session-files merge-base strategy

- Switch /api/tasks/:id/file-diffs to use merge-base (three-dot) diff instead of two-dot diff, matching session-files behavior
- Rewrite file-diffs route tests with comprehensive coverage and add count/detail consistency regression tests
- Update mission-types to export MissionSummary and fix MissionManager imports
- Remove obsolete MissionManager.test.tsx and mission-e2e.test.ts
- Update README docs to clarify board card count matches changed-files viewer
This commit is contained in:
gsxdsm
2026-04-03 19:31:13 -07:00
parent b8ccd55e12
commit fb9f92a0ca
4 changed files with 433 additions and 106 deletions

View File

@@ -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

View File

@@ -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<typeof import("node:child_process")>("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<Task[]> {
return Array.from(this.tasks.values());
}
async getTask(id: string): Promise<Task> {
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<Task[]> {
return [];
}
}
function createTask(overrides: Partial<Task> = {}): Task {
@@ -86,122 +71,397 @@ function createTask(overrides: Partial<Task> = {}): Task {
};
}
async function requestFileDiffs(app: Parameters<typeof get>[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<void>;
}
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<void>,
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<void>;
});
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());
});
});

View File

@@ -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" }));

View File

@@ -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<string, { statusCode: string; oldPath?: string }>();
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,