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