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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
118
packages/dashboard/src/__tests__/routes-diff-workspace.test.ts
Normal file
118
packages/dashboard/src/__tests__/routes-diff-workspace.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user