feat(workspace): show per-sub-repo changes in the task Changes tab

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-24 18:29:58 -07:00
parent e4a0118579
commit 98fc453f45
5 changed files with 412 additions and 158 deletions

View File

@@ -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<NormalizedFile[]>([]);
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);
}

View File

@@ -3399,7 +3399,7 @@ export function TaskDetailContent({
)}
</div>
) : activeTab === "changes" ? (
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} isWorkspace={isWorkspaceTask(workingTask)} />
) : activeTab === "review" ? (
<TaskReviewTab
task={task}

View File

@@ -201,6 +201,44 @@ describe("TaskChangesTab — worktree-backed (non-done tasks)", () => {
});
});
// 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(
<TaskChangesTab taskId="MULT-002" worktree={undefined} column={"in-review" as Column} isWorkspace />,
);
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(
<TaskChangesTab taskId="MULT-002" worktree={undefined} column={"in-review" as Column} isWorkspace />,
);
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);

View File

@@ -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<string>>();
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<typeof import("node:fs/promises")>("node:fs/promises");
return { ...actual, access: vi.fn(async () => undefined) };
});
import { createServer } from "../server.js";
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
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<Task[]> { 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<string, Record<string, string>> = {
"/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");
});
});

View File

@@ -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<Ag
return files;
}
interface WorktreeDetailedFile {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
additions: number;
deletions: number;
patch: string;
oldPath?: string;
}
/**
* Build the per-file detailed diff for a SINGLE worktree: committed
* (diffBase..HEAD) + staged + unstaged, with the committed set scoped to the
* task's own commits. Untracked files are intentionally excluded — at review
* time they are almost always build artifacts/cache/logs.
*
* Extracted so the single-repo diff endpoints AND the per-sub-repo workspace
* aggregation (computeWorkspaceTaskFiles) share ONE implementation. The
* single-repo `/tasks/:id/diff` and `/tasks/:id/file-diffs` paths must remain
* behaviour-identical to their previous inline form.
*/
async function computeWorktreeDetailedFiles(
taskLike: { id: string; baseBranch?: string; baseCommitSha?: string },
cwd: string,
timeoutMs: number,
): Promise<WorktreeDetailedFile[]> {
const diffBase = await resolveDiffBase(taskLike, cwd, "HEAD", undefined, { enableDisplayRecovery: true });
const fileMap = new Map<string, { statusCode: string; oldPath?: string }>();
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<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string }>;
},
rootDir: string,
timeoutMs: number,
): Promise<WorktreeDetailedFile[]> {
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<string, string>();
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<string, { statusCode: string; oldPath?: string }>();
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,