From 98fc453f45f6140847404d0a2d4fc245fb6be4bf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 18:29:58 -0700 Subject: [PATCH] feat(workspace): show per-sub-repo changes in the task Changes tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Changes / Files-changed tab showed nothing for multi-repo workspace tasks: the task-diff backend is single-repo throughout, and a workspace task has null task.worktree/task.branch (its per-repo state lives in workspaceWorktrees), so every path fell back to git-diff against the non-git workspace root → empty. Backend (register-session-diff-routes): extract the single-repo per-worktree detailed-diff into one shared helper (computeWorktreeDetailedFiles) and add a workspace branch to BOTH /tasks/:id/diff and /tasks/:id/file-diffs that runs before the single-repo logic: iterate sorted workspaceWorktrees, compute each sub-repo's diff in its own live worktree against that repo's baseCommitSha (done tasks fall back to the per-repo landed range in the sub-repo root), and aggregate with `${repoRel}/`-prefixed paths. Single-repo behavior is byte-for-byte preserved (renamed→modified fold retained; 58 existing diff-route tests pass). Frontend: TaskChangesTab takes an isWorkspace prop and no longer shows the single-repo "No worktree available" empty state for workspace tasks; TaskDetailModal passes isWorkspace={isWorkspaceTask(workingTask)}. Tests: backend aggregation (repo-prefixed paths + stats) and frontend rendering of workspace changes instead of the empty state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/TaskChangesTab.tsx | 15 +- .../app/components/TaskDetailModal.tsx | 2 +- .../__tests__/TaskChangesTab.test.tsx | 38 ++ .../__tests__/routes-diff-workspace.test.ts | 118 ++++++ .../routes/register-session-diff-routes.ts | 397 +++++++++++------- 5 files changed, 412 insertions(+), 158 deletions(-) create mode 100644 packages/dashboard/src/__tests__/routes-diff-workspace.test.ts diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 5b220116eb..1ac2815139 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -19,6 +19,14 @@ interface TaskChangesTabProps { projectId?: string; column?: ColumnId; mergeDetails?: MergeDetails; + /** + * True for a workspace (multi-repo) task. Such a task has no singular + * `worktree`/`branch` — its changes live in per-sub-repo worktrees, which the + * backend `/tasks/:id/diff` now aggregates (repo-prefixed paths). Used to skip + * the single-repo "No worktree available" empty state, which would otherwise + * fire on every workspace task because `worktree` is undefined. + */ + isWorkspace?: boolean; /** * Files modified by the task during execution, captured from the worktree. * Used as a last-resort fallback when the live worktree diff is empty or the @@ -127,7 +135,7 @@ interface NormalizedFile { * modifiedFiles view instead of showing a hard error. This preserves the prior * graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity. */ -export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles }: TaskChangesTabProps) { +export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles, isWorkspace }: TaskChangesTabProps) { const { t } = useTranslation("app"); const [files, setFiles] = useState([]); const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 }); @@ -309,7 +317,10 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai } // Non-done task without a worktree → only show fallback state when branch-fallback diff is empty. - if (!isDone && !worktree && files.length === 0) { + // A workspace task legitimately has no singular `worktree` (its changes come from the per-sub-repo + // aggregation), so it must NOT hit this "No worktree available" branch — fall through to the + // standard empty/populated rendering below. + if (!isDone && !worktree && !isWorkspace && files.length === 0) { if (modifiedFiles && modifiedFiles.length > 0) { return renderModifiedFilesFallback(modifiedFiles, false, undefined, "execution", t); } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 606724c8c9..66efd7109b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3399,7 +3399,7 @@ export function TaskDetailContent({ )} ) : activeTab === "changes" ? ( - + ) : activeTab === "review" ? ( { }); }); +// FNXC:Workspace 2026-06-25-00:40: a workspace task has no singular `worktree` — its changes come +// from the backend's per-sub-repo aggregation (repo-prefixed paths). It must render those instead of +// the single-repo "No worktree available" empty state. +describe("TaskChangesTab — workspace tasks", () => { + it("renders aggregated repo-prefixed files for a workspace task (no singular worktree)", async () => { + mockFetchTaskDiff.mockResolvedValue({ + files: [ + { path: "openvide/src/a.ts", status: "added", additions: 2, deletions: 0, patch: "@@ -0,0 +1,2 @@\n+a\n+aa" }, + { path: "swarmclaw/lib/b.ts", status: "modified", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n+b\n-old" }, + ], + stats: { filesChanged: 2, additions: 3, deletions: 1 }, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("openvide/src/a.ts")).toBeTruthy(); + }); + expect(screen.getByText("swarmclaw/lib/b.ts")).toBeTruthy(); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); + + it("does NOT show 'No worktree available' for an empty workspace task", async () => { + mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("No files modified.")).toBeTruthy(); + }); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); +}); + describe("TaskChangesTab — commit-backed (done tasks)", () => { it("loads diff from fetchTaskDiff for done task with commitSha", async () => { mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF); diff --git a/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts new file mode 100644 index 0000000000..d1fa0d2c47 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts @@ -0,0 +1,118 @@ +/* +FNXC:Workspace 2026-06-25-00:40: +A workspace (multi-repo) task has no singular `worktree`/`branch` — its changes live in per-sub-repo +worktrees recorded in `task.workspaceWorktrees`. `/tasks/:id/diff` and `/tasks/:id/file-diffs` must +aggregate each sub-repo's diff (computed in that sub-repo's worktree) and prefix every file path with +the sub-repo key, instead of diffing the non-git workspace root (which returns empty). + +We mock runGitCommand (keyed by cwd so each sub-repo returns its own files) and node:fs/promises +access (so the sub-repo worktrees "exist") — no real/slow git. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task } from "@fusion/core"; + +const runGitCommandMock = vi.fn<(...args: any[]) => Promise>(); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + // Per-repo base: the route passes the sub-repo's captured baseCommitSha through. + resolveDiffBase: vi.fn(async (task: any) => task.baseCommitSha), + runGitCommand: (...args: any[]) => runGitCommandMock(...args), +})); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { ...actual, access: vi.fn(async () => undefined) }; +}); + +import { createServer } from "../server.js"; + +class MockStore extends EventEmitter { + private tasks = new Map(); + getRootDir(): string { return "/ws-root"; } + getFusionDir(): string { return "/ws-root/.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); } + async getTaskCommitAssociationsByLineageId(): Promise<[]> { return []; } +} + +function workspaceTask(): Task { + return { + id: "MULT-002", title: "ws task", description: "", column: "in-review", + dependencies: [], steps: [], currentStep: 0, log: [], + createdAt: "2026-06-24T00:00:00.000Z", updatedAt: "2026-06-24T00:00:00.000Z", + worktree: undefined, branch: undefined, + workspaceWorktrees: { + // Intentionally non-alphabetical insertion to prove sorted, deterministic output. + swarmclaw: { worktreePath: "/wt/swarmclaw", branch: "fusion/mult-002", baseCommitSha: "baseS" }, + openvide: { worktreePath: "/wt/openvide", branch: "fusion/mult-002", baseCommitSha: "baseO" }, + }, + } as Task; +} + +// Per-cwd git responses. Anything not listed throws — restrictActiveCommittedFilesToOwnTask's +// attribution probes hit that and are swallowed (display-only), preserving the broad diff. +const RESPONSES: Record> = { + "/wt/openvide": { + "diff --name-status -M baseO..HEAD": "A\tsrc/a.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseO -- src/a.ts": "+a\n+aa\n", + }, + "/wt/swarmclaw": { + "diff --name-status -M baseS..HEAD": "M\tlib/b.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseS -- lib/b.ts": "+b\n-old\n", + }, +}; + +describe("workspace task diff aggregation", () => { + beforeEach(() => { + vi.clearAllMocks(); + runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => { + const repo = (cwd && RESPONSES[cwd]) || {}; + const key = gitArgs.join(" "); + if (key in repo) return repo[key] ?? ""; + throw new Error(`Unexpected git command [${cwd}]: ${key}`); + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("/diff aggregates per-sub-repo files with repo-prefixed paths and summed stats", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/diff"); + + expect(res.status).toBe(200); + expect(res.body.files.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.files.find((f: any) => f.path === "openvide/src/a.ts").status).toBe("added"); + expect(res.body.stats).toEqual({ filesChanged: 2, additions: 3, deletions: 1 }); + }); + + it("/file-diffs returns repo-prefixed per-file patches", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/file-diffs"); + + expect(res.status).toBe(200); + expect(res.body.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.find((f: any) => f.path === "swarmclaw/lib/b.ts").diff).toContain("-old"); + }); +}); diff --git a/packages/dashboard/src/routes/register-session-diff-routes.ts b/packages/dashboard/src/routes/register-session-diff-routes.ts index b41ec8d5ec..3b848688ab 100644 --- a/packages/dashboard/src/routes/register-session-diff-routes.ts +++ b/packages/dashboard/src/routes/register-session-diff-routes.ts @@ -1,6 +1,8 @@ import { access } from "node:fs/promises"; +import { join } from "node:path"; import type { Request, Router } from "express"; import type { RunAuditEvent, RunAuditEventFilter } from "@fusion/core"; +import { isWorkspaceTask } from "@fusion/core"; import { ApiError, notFound, rethrowAsApiError } from "../api-error.js"; import { resolveDiffBase, runGitCommand } from "./resolve-diff-base.js"; import { countPatchLines } from "./diff-counts.js"; @@ -372,6 +374,188 @@ async function collectDoneRangeFiles(range: string, rootDir: string): Promise { + const diffBase = await resolveDiffBase(taskLike, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); + + const fileMap = new Map(); + + if (diffBase) { + try { + const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, timeoutMs)).trim(); + for (const line of committedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // committed diff failed + } + } + + await restrictActiveCommittedFilesToOwnTask(fileMap, { + taskId: taskLike.id, + diffBase, + worktreePath: cwd, + runGit: (args) => runGitCommand(args, cwd, timeoutMs), + }); + + try { + const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of stagedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // staged diff failed + } + + try { + const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of workingTreeOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // working tree diff failed + } + + const results: WorktreeDetailedFile[] = []; + for (const [filePath, { statusCode, oldPath }] of fileMap.entries()) { + if (!filePath) continue; + + 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 patch = ""; + try { + patch = diffBase + ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, timeoutMs) + : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, timeoutMs); + } catch { + // ignore individual file errors + } + + const { additions, deletions } = countPatchLines(patch); + results.push(oldPath ? { path: filePath, status, additions, deletions, patch, oldPath } : { path: filePath, status, additions, deletions, patch }); + } + + return results; +} + +/** + * Aggregate a workspace task's changed files across ALL acquired sub-repo + * worktrees. A workspace task has no singular `task.worktree`/`task.branch` + * (those are null by design); its per-repo state lives in + * `task.workspaceWorktrees`. Each sub-repo's diff is computed in its own live + * worktree (in-progress/in-review) or, when that worktree is gone (done tasks), + * from its landed range in the sub-repo root. Every file path is prefixed with + * the sub-repo key (e.g. `openvide/src/foo.ts`) so the Changes tab shows which + * sub-repo each file belongs to. A missing/unreadable sub-repo is skipped + * best-effort rather than failing the whole response. + */ +async function computeWorkspaceTaskFiles( + task: { + id: string; + baseBranch?: string; + workspaceWorktrees?: Record; + }, + rootDir: string, + timeoutMs: number, +): Promise { + const worktrees = task.workspaceWorktrees ?? {}; + const all: WorktreeDetailedFile[] = []; + + // Deterministic, repo-sorted order so the aggregated list is stable. + for (const repoRel of Object.keys(worktrees).sort()) { + const entry = worktrees[repoRel]; + if (!entry) continue; + + let repoFiles: WorktreeDetailedFile[] = []; + + // Prefer the live sub-repo worktree (in-progress / in-review). + let worktreeUsable = false; + if (entry.worktreePath) { + try { + await access(entry.worktreePath); + worktreeUsable = true; + } catch { + worktreeUsable = false; + } + } + if (worktreeUsable) { + try { + repoFiles = await computeWorktreeDetailedFiles( + // Per-repo base: use the sub-repo's own captured fork point, with the + // workspace task's baseBranch stripped so resolveDiffBase uses the + // per-repo baseCommitSha rather than a shared workspace branch. + { id: task.id, baseBranch: undefined, baseCommitSha: entry.baseCommitSha }, + entry.worktreePath, + timeoutMs, + ); + } catch { + repoFiles = []; + } + } + + // Fallback: landed range in the sub-repo root (a done task whose per-repo + // worktree was already cleaned up). Each sub-repo lands independently with + // its own baseCommitSha → landedSha. + if (repoFiles.length === 0 && entry.baseCommitSha && entry.landedSha) { + const repoRootDir = join(rootDir, repoRel); + try { + const rangeFiles = await collectDoneRangeFiles(`${entry.baseCommitSha}..${entry.landedSha}`, repoRootDir); + repoFiles = rangeFiles.map((file) => ({ + path: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + } catch { + repoFiles = []; + } + } + + for (const file of repoFiles) { + all.push({ + ...file, + path: `${repoRel}/${file.path}`, + oldPath: file.oldPath ? `${repoRel}/${file.oldPath}` : undefined, + }); + } + } + + return all; +} + function extractCommitShaCandidate(event: { target?: unknown; metadata?: unknown; payload?: unknown; newValue?: unknown }): string | undefined { if (typeof event.target === "string" && event.target.trim()) { return event.target.trim(); @@ -730,6 +914,30 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // Workspace tasks have no singular worktree/branch; their changes live in + // per-sub-repo worktrees. Aggregate across them (repo-prefixed paths) and + // short-circuit before the single-repo logic, which would diff the non-git + // workspace root and return empty. + if (isWorkspaceTask(task)) { + const workspaceFiles = await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), 10000); + const files = workspaceFiles.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? "modified" : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + res.json({ + files, + stats: { + filesChanged: files.length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }, + }); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -906,85 +1114,18 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = resolvedWorktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - - // Only count files actually changed by the task: committed (base..HEAD) - // + staged + unstaged. Untracked files are intentionally excluded — at - // review time they're almost always build artifacts/cache/logs that - // weren't in .gitignore, not real task changes. - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 10000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // committed diff failed - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 10000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // staged diff failed - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // working tree diff failed - } - - const files: Array<{ - path: string; - status: "added" | "modified" | "deleted"; - additions: number; - deletions: number; - patch: string; - }> = []; - - for (const [filePath, statusCode] of fileMap) { - if (!filePath) continue; - - let status: "added" | "modified" | "deleted"; - if (statusCode.startsWith("A")) status = "added"; - else if (statusCode.startsWith("D")) status = "deleted"; - else status = "modified"; - - let patch = ""; - try { - patch = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 10000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 10000); - } catch { - // ignore individual file errors - } - - const { additions, deletions } = countPatchLines(patch); - - files.push({ path: filePath, status, additions, deletions, patch }); - } + // Single-repo detailed diff (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Renames fold to + // "modified" here (the /diff shape has no "renamed" status), matching the + // previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, 10000); + const files = detailed.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? ("modified" as const) : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); const stats = { filesChanged: files.length, @@ -1010,6 +1151,18 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // Workspace tasks aggregate per-sub-repo patches (repo-prefixed paths); + // short-circuit before the single-repo logic that diffs the non-git root. + if (isWorkspaceTask(task)) { + const workspaceFiles = (await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), 5000)) + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); + res.json(workspaceFiles); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -1153,83 +1306,17 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = worktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - // Only files actually changed by the task: committed + staged + unstaged. - // Untracked files (build artifacts, cache, logs) are intentionally - // excluded so the count matches "ACTUAL files changed by the task". - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 5000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // continue with working-tree-only changes - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 5000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore staged diff failures - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore unstaged diff failures - } - - const files = []; - - for (const [filePath, { statusCode, oldPath }] of fileMap.entries()) { - 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 = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 5000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 5000); - } catch { - diff = ""; - } - - if (!diff) { - continue; - } - - files.push(oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff }); - } + // Single-repo per-file patches (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Files with an empty + // patch (e.g. pure renames with no content change) are dropped, matching + // the previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, 5000); + const files = detailed + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); fileDiffsCache.set(task.id, { files,