fix(workspace): suppress spurious 'Not a git repository' toast in Git Manager (#1749)

## Problem
Opening the Git Manager on a multi-repo **workspace** project rendered
the sub-repo dropdown correctly but **also toasted "Not a git
repository"** every time.

## Cause
On open, the section data fetch fires immediately with no `repoPath`
(the `selectedRepo` hasn't resolved yet), so the git status request
targets the **non-git browse-only workspace root** and fails `Not a git
repository`. `fetchWorkspaceRepos` resolves a tick later and the fetch
re-runs against a real sub-repo — but the first failure had already
toasted.

## Fix
Track workspace detection in a ref and suppress that single benign
root-race error: no `repoPath` + `Not a git repository` while detection
is still pending or has confirmed a workspace. A genuinely broken
**non-workspace** project still surfaces the error — once detection
settles as non-workspace, a single *guarded* re-fetch re-surfaces it.
The guard ensures no redundant fetch in the common non-workspace-OK
path, preserving existing call-count expectations.

## Tests
- Added the missing `fetchWorkspaceRepos` api mock — a pre-existing gap
(from the git-manager commit) that had broken the **entire**
GitManagerModal suite at import (130 tests).
- Positive regression: workspace project → no `Not a git repository`
toast.
- Negative control: real non-workspace broken repo → the error still
toasts.

Dashboard typecheck + lint clean; my 2 new tests pass and no
previously-passing test regressed (8 unrelated pre-existing failures
remain, untouched by this change).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1749">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Workspace tasks now return aggregated, repo-prefixed diff results for
both detailed diffs and file-diff listings, including consistent
file/stats aggregation across sub-repositories.
* **Bug Fixes**
* Prevented an initial, harmless “Not a git repository” message from
being shown during early workspace detection; genuine errors still
display the standard error toast.
* Improved workspace detection and retry behavior so the UI loads the
correct repo context after project switching.
* Updated the Task Changes UI to handle workspace tasks without
incorrectly showing the “No worktree available” state.
* **Tests**
* Added/expanded coverage for workspace diff aggregation and
workspace-related UI/error behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-24 19:18:12 -07:00
committed by GitHub
7 changed files with 641 additions and 160 deletions

View File

@@ -267,6 +267,34 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [workspaceRepos, setWorkspaceRepos] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const gitRepoPath = selectedRepo ?? undefined;
/*
FNXC:Workspace 2026-06-25-00:10:
In a workspace the project root is a non-git browse-only directory. On modal open the section fetch
fires immediately with no repoPath (selectedRepo not yet resolved), so a git status against the root
returns "Not a git repository" and toasts a spurious error on every open — even though the repo
dropdown renders correctly. fetchWorkspaceRepos resolves a tick later and re-fetches against a real
sub-repo. We track detection status in a REF (read inside the async fetch catch without a stale
closure or extra render dep) so we can SUPPRESS that one benign root-race error: a "Not a git
repository" with no repoPath while detection is unresolved OR has detected a workspace. A genuine
broken non-workspace project (resolved, repos empty) still surfaces the error normally.
*/
const workspaceDetectionRef = useRef<{ resolved: boolean; isWorkspace: boolean }>({ resolved: false, isWorkspace: false });
// Tracks whether the most recent fetch suppressed a root-race error, and a state tick that flips
// when detection resolves — together they let a genuinely-broken NON-workspace project re-surface
// the error (a single re-fetch) after detection settles, without adding a redundant fetch to the
// common non-workspace-OK path (where the first fetch already succeeded).
const suppressedRootRaceRef = useRef(false);
const [detectionResolved, setDetectionResolved] = useState(false);
/*
FNXC:Workspace 2026-06-25-09:40 (detection generation guard):
A rapid projectId switch (or close→reopen) can leave a previous project's fetchWorkspaceRepos
promise in flight. When it resolves it must NOT overwrite the CURRENT project's detection verdict —
doing so could suppress a real error for the new project or mis-fire the re-surface effect. Each
detection run is stamped with a monotonically increasing generation; only the latest run is allowed
to mutate detection state, and the effect cleanup bumps the generation so a superseded/unmounted run
is abandoned.
*/
const detectionGenerationRef = useRef(0);
// ── Changes state
const [fileChanges, setFileChanges] = useState<GitFileChange[]>([]);
@@ -322,6 +350,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!isOpen) return;
setLoading(true);
setSectionError(null);
suppressedRootRaceRef.current = false;
try {
switch (activeSection) {
case "status": {
@@ -375,8 +404,30 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
}
}
} catch (err) {
setSectionError(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"));
addToast(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"), "error");
const message = getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data");
/*
FNXC:Workspace 2026-06-25-00:10:
Suppress the benign workspace-root race: on open, the first fetch fires before selectedRepo
resolves (no repoPath → the non-git browse root), which fails "Not a git repository". A workspace
re-fetches against a real sub-repo a tick later. Only swallow this when there is NO repoPath AND
detection is still pending OR has confirmed a workspace; a resolved non-workspace project surfaces
a genuine "Not a git repository" normally.
*/
const detection = workspaceDetectionRef.current;
const isWorkspaceRootRace =
gitRepoPath === undefined &&
/not a git repository/i.test(message) &&
(!detection.resolved || detection.isWorkspace);
if (isWorkspaceRootRace) {
// Benign: defer reporting. A workspace re-fetches against its sub-repo (selectedRepo change);
// a non-workspace re-fetches once via the detection-resolved effect below, surfacing any real
// error then.
suppressedRootRaceRef.current = true;
setSectionError(null);
} else {
setSectionError(message);
addToast(message, "error");
}
} finally {
setLoading(false);
}
@@ -909,20 +960,46 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
selectedRepo in the effect deps, preserving the projectId-keyed intent.
*/
useEffect(() => {
// Reset detection on project switch so a stale verdict can't suppress a real error. The
// generation guard (see ref note above) makes a superseded in-flight resolution a no-op.
const gen = ++detectionGenerationRef.current;
workspaceDetectionRef.current = { resolved: false, isWorkspace: false };
suppressedRootRaceRef.current = false;
setDetectionResolved(false);
fetchWorkspaceRepos(projectId)
.then((result) => {
if (gen !== detectionGenerationRef.current) return;
const repos = result.repos;
workspaceDetectionRef.current = { resolved: true, isWorkspace: repos.length > 0 };
setWorkspaceRepos(repos);
setSelectedRepo((current) =>
current && repos.includes(current) ? current : (repos[0] ?? null),
);
})
.catch(() => {
if (gen !== detectionGenerationRef.current) return;
workspaceDetectionRef.current = { resolved: true, isWorkspace: false };
setWorkspaceRepos([]);
setSelectedRepo(null);
})
.finally(() => {
if (gen !== detectionGenerationRef.current) return;
setDetectionResolved(true);
});
// Bump the generation on cleanup so an unmounted/superseded run's late resolution is abandoned.
return () => { detectionGenerationRef.current++; };
}, [projectId]); // keyed on projectId; selectedRepo is revalidated via the functional updater
// FNXC:Workspace 2026-06-25-00:10: once detection settles, re-surface a suppressed root-race error
// for a NON-workspace project (a genuinely broken/non-git repo). A workspace already re-fetches via
// the selectedRepo change, so we skip it here to avoid a redundant second fetch.
useEffect(() => {
if (isOpen && detectionResolved && suppressedRootRaceRef.current && !workspaceDetectionRef.current.isWorkspace) {
suppressedRootRaceRef.current = false;
void fetchSectionData();
}
}, [isOpen, detectionResolved, fetchSectionData]);
const handleSyncIntegrationTip = useCallback(async () => {
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
const worktreePath = rootDir;

View File

@@ -19,6 +19,15 @@ interface TaskChangesTabProps {
projectId?: string;
column?: ColumnId;
mergeDetails?: MergeDetails;
/**
* FNXC:Workspace 2026-06-25-09:40:
* 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 +136,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 +318,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

@@ -70,6 +70,10 @@ vi.mock("../../api", async () => {
fetchAheadCommits: vi.fn(),
fetchRemoteCommits: vi.fn(),
fetchBranchCommits: vi.fn(),
// FNXC:Test 2026-06-25-00:10: GitManagerModal detects workspace sub-repos on mount via
// fetchWorkspaceRepos; the mock was never added when that call landed, breaking the whole suite
// at import. Default to a non-workspace project ({ repos: [] }) so the root git path is exercised.
fetchWorkspaceRepos: vi.fn().mockResolvedValue({ repos: [] }),
};
});
@@ -112,6 +116,7 @@ import {
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
fetchWorkspaceRepos,
} from "../../api";
import { subscribeSse } from "../../sse-bus";
@@ -284,6 +289,64 @@ describe("GitManagerModal", () => {
(fetchRemoteCommits as any).mockResolvedValue([]);
});
// ── Workspace root-race toast suppression ───────────────────
// FNXC:Workspace 2026-06-25-00:10: a workspace project's root is non-git, so the first git status
// (no repoPath yet) fails "Not a git repository". That benign race must NOT toast; a real
// non-workspace project with the same error must.
it("does NOT toast 'Not a git repository' for a workspace project's initial root-race fetch", async () => {
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["openvide", "swarmclaw"] });
// Root (no repoPath) → not a git repo; a real sub-repo → resolves.
(fetchGitStatus as any).mockImplementation((_pid: unknown, _opts: unknown, repoPath?: string) =>
repoPath
? Promise.resolve({ branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 })
: Promise.reject(new Error("Not a git repository")),
);
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
// Wait until the re-fetch against the selected sub-repo has happened.
await waitFor(() => {
expect((fetchGitStatus as any).mock.calls.some((c: unknown[]) => c[2] === "openvide")).toBe(true);
});
expect(mockAddToast).not.toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
it("DOES toast 'Not a git repository' for a real non-workspace project", async () => {
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] });
(fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository"));
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
});
it("does not let a stale workspace project's late detection suppress a real error after a rapid project switch", async () => {
// FNXC:Workspace 2026-06-25-09:40 (generation guard): switch from workspace project A (whose
// fetchWorkspaceRepos resolves LATE) to broken non-workspace project B before A resolves. A's late
// "workspace" verdict must be abandoned (generation guard) so it can't suppress B's real error.
let resolveA: (v: { repos: string[] }) => void = () => {};
const aPromise = new Promise<{ repos: string[] }>((r) => { resolveA = r; });
(fetchWorkspaceRepos as any).mockImplementation((pid: string) =>
pid === "projA" ? aPromise : Promise.resolve({ repos: [] }));
(fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository"));
const { rerender } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="projA" />,
);
// Switch to B before A's detection resolves.
rerender(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="projB" />);
// A resolves late as a workspace — must be ignored for the now-current project B.
resolveA({ repos: ["openvide"] });
// B is a genuinely broken non-workspace repo → its error must still surface.
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
});
// ── Basic Rendering ─────────────────────────────────────────
it("renders nothing when not open", () => {

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,154 @@
/*
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("preserves deterministic repo-sorted order across the concurrent (parallelized) aggregation", async () => {
// FNXC:WorkspaceDiff 2026-06-25-09:40: sub-repos are now diffed concurrently; the output must
// still be sorted by repo key regardless of which sub-repo's git calls finish first. Three repos
// inserted out of order, with the first-sorted repo deliberately given the slowest git response.
const task = workspaceTask();
(task as any).workspaceWorktrees = {
zulu: { worktreePath: "/wt/zulu", branch: "fusion/mult-002", baseCommitSha: "baseZ" },
alpha: { worktreePath: "/wt/alpha", branch: "fusion/mult-002", baseCommitSha: "baseA" },
mike: { worktreePath: "/wt/mike", branch: "fusion/mult-002", baseCommitSha: "baseM" },
};
const resp: Record<string, Record<string, string>> = {
"/wt/alpha": { "diff --name-status -M baseA..HEAD": "A\ta.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseA -- a.ts": "+x\n" },
"/wt/mike": { "diff --name-status -M baseM..HEAD": "A\tm.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseM -- m.ts": "+y\n" },
"/wt/zulu": { "diff --name-status -M baseZ..HEAD": "A\tz.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseZ -- z.ts": "+w\n" },
};
runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => {
const key = gitArgs.join(" ");
const repo = (cwd && resp[cwd]) || {};
if (key in repo) {
// Make the first-sorted repo (alpha) resolve LAST to prove order is by key, not completion.
if (cwd === "/wt/alpha") await new Promise((r) => setTimeout(r, 5));
return repo[key] ?? "";
}
throw new Error(`Unexpected git command [${cwd}]: ${key}`);
});
const store = new MockStore();
store.addTask(task);
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(["alpha/a.ts", "mike/m.ts", "zulu/z.ts"]);
});
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,235 @@ 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;
}
/*
FNXC:WorkspaceDiff 2026-06-25-09:40:
Per-call git timeouts for the task-diff endpoints. /diff allows a longer budget than /file-diffs
because the former drives the primary Changes view; both are named so the difference is visible at a
glance and the literals are not duplicated across call sites.
*/
const DIFF_TIMEOUT_MS = 10_000;
const FILE_DIFFS_TIMEOUT_MS = 5_000;
/*
FNXC:WorkspaceDiff 2026-06-25-09:40:
Bounded-concurrency mapper. A workspace task fans the diff out across N sub-repos × M files; running
those git subprocesses strictly serially makes the Changes tab block for a long time on large
multi-repo tasks (each per-file `git diff` is an independent subprocess). Run them concurrently with a
cap so we get parallel wall-clock without spawning an unbounded herd of git processes. Output order is
preserved (results indexed by input position) so the aggregated diff stays deterministic.
*/
async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let cursor = 0;
const workerCount = Math.max(1, Math.min(limit, items.length));
const workers = Array.from({ length: workerCount }, async () => {
for (;;) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await fn(items[index]!, index);
}
});
await Promise.all(workers);
return results;
}
/**
* 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
}
/*
FNXC:WorkspaceDiff 2026-06-25-09:40:
The per-file `git diff` patch fetch is the dominant cost (one subprocess per changed file). Run it
with bounded concurrency instead of a serial await loop — independent files do not depend on each
other, so this collapses M serial git spawns to ~M/limit wall-clock. We deliberately do NOT skip the
patch for deleted files: /file-diffs filters out empty-patch entries and the patch supplies the
additions/deletions counts, so a delete needs its real patch to stay visible and counted. Status
uses the shared parseStatusCode helper (single source of truth for the A/D/R/M mapping).
*/
const entries = Array.from(fileMap.entries()).filter(([filePath]) => Boolean(filePath));
const results = await mapWithConcurrency(entries, 8, async ([filePath, { statusCode, oldPath }]) => {
const status = parseStatusCode(statusCode);
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);
return 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 ?? {};
/*
FNXC:WorkspaceDiff 2026-06-25-09:40:
Resolve each sub-repo's diff CONCURRENTLY (bounded) rather than awaiting them one at a time: every
sub-repo's git work is independent, so a serial loop made the aggregate cost N×(per-repo) and could
block the response for a long time on a many-repo task. Keys are sorted first and mapped by position,
so the aggregated output stays in deterministic repo-sorted order regardless of completion order.
*/
const repoRels = Object.keys(worktrees).sort();
const perRepo = await mapWithConcurrency(repoRels, 4, async (repoRel) => {
const entry = worktrees[repoRel];
if (!entry) return [] as WorktreeDetailedFile[];
let repoFiles: WorktreeDetailedFile[] = [];
// Prefer the live sub-repo worktree (in-progress / in-review). The access()
// probe is an optimistic fast-path skip; the try/catch below is the real guard.
let worktreeUsable = false;
if (entry.worktreePath) {
try {
await access(entry.worktreePath);
worktreeUsable = true;
} catch {
// worktree gone → fall through to the landed-range fallback
}
}
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.
// FNXC:WorkspaceDiff 2026-06-25-09:40: collectDoneRangeFiles returns AggregatedDoneTaskFile, which
// carries no oldPath, so a renamed file's rename-SOURCE is unavailable on this done fallback (the
// file still shows under its new path). The live-worktree path above does preserve oldPath.
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 = [];
}
}
// Prefix every path with the sub-repo key so the Changes tab shows which repo each file is in.
return repoFiles.map((file) => ({
...file,
path: `${repoRel}/${file.path}`,
oldPath: file.oldPath ? `${repoRel}/${file.oldPath}` : undefined,
}));
});
return perRepo.flat();
}
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 +961,31 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
return;
}
// FNXC:WorkspaceDiff 2026-06-25-09:40:
// 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. renamed→modified is folded
// to match the /diff contract (which has no 'renamed' status; /file-diffs keeps it).
if (isWorkspaceTask(task)) {
const workspaceFiles = await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), DIFF_TIMEOUT_MS);
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 +1162,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, DIFF_TIMEOUT_MS);
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 +1199,20 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
return;
}
// FNXC:WorkspaceDiff 2026-06-25-09:40:
// Workspace tasks aggregate per-sub-repo patches (repo-prefixed paths); short-circuit before the
// single-repo logic that diffs the non-git root. Unlike /diff, /file-diffs preserves the
// 'renamed' status + oldPath. Empty-patch entries are dropped (parity with the single-repo path).
if (isWorkspaceTask(task)) {
const workspaceFiles = (await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), FILE_DIFFS_TIMEOUT_MS))
.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 +1356,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, FILE_DIFFS_TIMEOUT_MS);
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,