feat(KB-651): add changed files diff viewer for tasks

- Add backend API endpoint to get changed files and diffs for a task worktree
- Add useChangedFiles hook for fetching and managing diff data
- Create ChangedFilesModal component with file list and diff viewer
- Integrate modal into TaskCard with click handler to view changes
- Add frontend API integration and type definitions
- Include unit tests for hook and modal components
- Add changeset for the new feature
- Remove deprecated mission-related test files
This commit is contained in:
gsxdsm
2026-04-01 02:01:31 -07:00
parent d415e9f5fa
commit e21ea42d45
14 changed files with 622 additions and 9 deletions

View File

@@ -46,6 +46,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation
- **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks

View File

@@ -10,6 +10,7 @@ import { SetupWizardModal } from "./components/SetupWizardModal";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { FileBrowserModal } from "./components/FileBrowserModal";
import { ChangedFilesModal } from "./components/ChangedFilesModal";
import { SettingsModal } from "./components/SettingsModal";
import { PlanningModeModal } from "./components/PlanningModeModal";
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
@@ -45,6 +46,7 @@ function AppInner() {
const [terminalOpen, setTerminalOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [changedFilesState, setChangedFilesState] = useState<{ taskId: string; worktree: string | undefined; column: string } | null>(null);
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
@@ -339,9 +341,12 @@ function AppInner() {
setFilesOpen(true);
}, []);
const handleOpenFilesForTask = useCallback((taskId: string) => {
setFileBrowserWorkspace(taskId);
setFilesOpen(true);
const handleOpenChangedFiles = useCallback((taskId: string, worktree: string | undefined, column: string) => {
setChangedFilesState({ taskId, worktree, column });
}, []);
const handleCloseChangedFiles = useCallback(() => {
setChangedFilesState(null);
}, []);
const handleWorkspaceChange = useCallback((workspace: string) => {
@@ -427,7 +432,7 @@ function AppInner() {
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenFilesForTask={handleOpenFilesForTask}
onOpenFilesForTask={handleOpenChangedFiles}
projectId={currentProject?.id}
projectName={currentProject?.name}
/>
@@ -550,6 +555,15 @@ function AppInner() {
onWorkspaceChange={handleWorkspaceChange}
/>
)}
{changedFilesState && (
<ChangedFilesModal
taskId={changedFilesState.taskId}
worktree={changedFilesState.worktree}
column={changedFilesState.column}
isOpen={true}
onClose={handleCloseChangedFiles}
/>
)}
<UsageIndicator
isOpen={usageOpen}
onClose={handleCloseUsage}

View File

@@ -283,6 +283,17 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
return api<string[]>(`/tasks/${taskId}/session-files`);
}
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
diff: string;
oldPath?: string;
}
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
}
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}

View File

@@ -35,7 +35,7 @@ interface BoardProps {
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;

View File

@@ -0,0 +1,134 @@
import { useEffect, useMemo } from "react";
import { FileEdit, FileMinus, FilePlus, FileSymlink, FolderGit2, X } from "lucide-react";
import { useChangedFiles } from "../hooks/useChangedFiles";
import type { TaskFileDiff } from "../api";
interface ChangedFilesModalProps {
taskId: string;
worktree: string | undefined;
column: string;
isOpen: boolean;
onClose: () => void;
}
function getStatusLabel(status: TaskFileDiff["status"]): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "renamed":
return "R";
default:
return "M";
}
}
function getStatusIcon(status: TaskFileDiff["status"]) {
switch (status) {
case "added":
return <FilePlus size={16} />;
case "deleted":
return <FileMinus size={16} />;
case "renamed":
return <FileSymlink size={16} />;
default:
return <FileEdit size={16} />;
}
}
function getDiffStat(diff: string): string {
const lines = diff.split("\n");
const statLines = lines.filter((line) => line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ "));
return statLines.join("\n").trim();
}
export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }: ChangedFilesModalProps) {
const { files, loading, error, selectedFile, setSelectedFile } = useChangedFiles(taskId, worktree, column);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
const selectedStat = useMemo(() => (selectedFile ? getDiffStat(selectedFile.diff) : ""), [selectedFile]);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div className="modal file-browser-modal changed-files-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header file-browser-modal-header">
<div className="file-browser-header-title">
<FolderGit2 size={18} />
<span>Changed Files {taskId}</span>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close changed files viewer">
<X size={20} />
</button>
</div>
<div className="file-browser-body">
<aside className="file-browser-sidebar" style={{ flex: "0 0 30%" }}>
{loading ? (
<div className="gm-diff-loading">Loading changed files</div>
) : error ? (
<div className="gm-diff-error">{error}</div>
) : files.length === 0 ? (
<div className="file-browser-empty-state">No files changed</div>
) : (
<div className="file-browser-list" role="list" aria-label="Changed files list">
{files.map((file) => {
const active = selectedFile?.path === file.path && selectedFile?.oldPath === file.oldPath;
return (
<button
key={`${file.oldPath ?? ""}:${file.path}`}
type="button"
role="listitem"
className={`file-browser-entry ${active ? "active" : ""}`}
onClick={() => setSelectedFile(file)}
>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{getStatusIcon(file.status)}
<span>{file.path}</span>
</span>
<span className="badge">{getStatusLabel(file.status)}</span>
</button>
);
})}
</div>
)}
</aside>
<section className="file-browser-content" style={{ flex: "0 0 70%" }}>
{!loading && !error && files.length > 0 && !selectedFile ? (
<div className="file-browser-empty-state">Select a file to view changes</div>
) : null}
{selectedFile ? (
<div className="gm-diff-section" aria-label={`Diff for ${selectedFile.path}`}>
<div className="file-browser-toolbar">
<div className="file-browser-file-info">
<strong>{selectedFile.path}</strong>
<span className="badge">{getStatusLabel(selectedFile.status)}</span>
{selectedFile.oldPath ? <span>Renamed from {selectedFile.oldPath}</span> : null}
</div>
</div>
<div className="gm-diff-viewer">
{selectedStat ? <pre className="gm-diff-stat">{selectedStat}</pre> : null}
<pre className="gm-diff-patch">{selectedFile.diff || "No diff available"}</pre>
</div>
</div>
) : null}
</section>
</div>
</div>
</div>
);
}

View File

@@ -45,7 +45,7 @@ interface ColumnProps {
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
}
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {

View File

@@ -43,7 +43,7 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
}
function areTaskBadgeInfosEqual(
@@ -694,7 +694,7 @@ function TaskCardComponent({
className="card-session-files"
onClick={(e) => {
e.stopPropagation();
onOpenFilesForTask?.(task.id);
onOpenFilesForTask?.(task.id, task.worktree, task.column);
}}
disabled={!onOpenFilesForTask}
>

View File

@@ -15,7 +15,7 @@ interface WorktreeGroupProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
}
function WorktreeGroupComponent({

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ChangedFilesModal } from "../ChangedFilesModal";
import * as changedFilesHook from "../../hooks/useChangedFiles";
vi.mock("../../hooks/useChangedFiles");
const mockUseChangedFiles = vi.mocked(changedFilesHook.useChangedFiles);
describe("ChangedFilesModal", () => {
const mockOnClose = vi.fn();
const mockSetSelectedFile = vi.fn();
beforeEach(() => {
vi.resetAllMocks();
mockUseChangedFiles.mockReturnValue({
files: [
{ path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n+hello" },
{ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" },
],
loading: false,
error: null,
selectedFile: { path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n+hello" },
setSelectedFile: mockSetSelectedFile,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders changed files and selected diff", () => {
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
expect(screen.getByText("Changed Files — KB-651")).toBeInTheDocument();
expect(screen.getByText("src/a.ts")).toBeInTheDocument();
expect(screen.getByLabelText("Diff for src/a.ts")).toBeInTheDocument();
expect(screen.getByText(/\+hello/)).toBeInTheDocument();
});
it("allows selecting another file from the sidebar", () => {
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /src\/b.ts/i }));
expect(mockSetSelectedFile).toHaveBeenCalledWith({ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" });
});
it("shows an empty state when there are no changed files", () => {
mockUseChangedFiles.mockReturnValue({
files: [],
loading: false,
error: null,
selectedFile: null,
setSelectedFile: mockSetSelectedFile,
});
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
expect(screen.getByText("No files changed")).toBeInTheDocument();
});
it("closes on Escape", () => {
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useChangedFiles } from "../useChangedFiles";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchTaskFileDiffs: vi.fn(),
}));
const mockFetchTaskFileDiffs = vi.mocked(api.fetchTaskFileDiffs);
describe("useChangedFiles", () => {
beforeEach(() => {
mockFetchTaskFileDiffs.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it("fetches changed files for active tasks with a worktree and auto-selects first file", async () => {
mockFetchTaskFileDiffs.mockResolvedValueOnce([
{ path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts" },
{ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" },
]);
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.files).toHaveLength(2);
expect(result.current.selectedFile?.path).toBe("src/a.ts");
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651");
});
it("does not fetch for tasks without worktrees or inactive columns", async () => {
const { result: noWorktree } = renderHook(() => useChangedFiles("KB-651", undefined, "in-progress"));
const { result: inactive } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "todo"));
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
await waitFor(() => expect(inactive.current.loading).toBe(false));
expect(noWorktree.current.files).toEqual([]);
expect(noWorktree.current.selectedFile).toBeNull();
expect(inactive.current.files).toEqual([]);
expect(inactive.current.selectedFile).toBeNull();
expect(mockFetchTaskFileDiffs).not.toHaveBeenCalled();
});
it("returns an error state on fetch failure", async () => {
mockFetchTaskFileDiffs.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-review"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
expect(result.current.selectedFile).toBeNull();
expect(result.current.error).toBe("boom");
});
it("allows selecting a different file after data loads", async () => {
mockFetchTaskFileDiffs.mockResolvedValueOnce([
{ path: "src/a.ts", status: "modified", diff: "first" },
{ path: "src/b.ts", status: "added", diff: "second" },
]);
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSelectedFile(result.current.files[1]!);
});
expect(result.current.selectedFile?.path).toBe("src/b.ts");
});
});

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { fetchTaskFileDiffs, type TaskFileDiff } from "../api";
const ACTIVE_COLUMNS = new Set(["in-progress", "in-review"]);
interface UseChangedFilesResult {
files: TaskFileDiff[];
loading: boolean;
error: string | null;
selectedFile: TaskFileDiff | null;
setSelectedFile: (file: TaskFileDiff) => void;
}
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string): UseChangedFilesResult {
const [files, setFiles] = useState<TaskFileDiff[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<TaskFileDiff | null>(null);
useEffect(() => {
if (!taskId || !worktree || !ACTIVE_COLUMNS.has(column)) {
setFiles([]);
setLoading(false);
setError(null);
setSelectedFile(null);
return;
}
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const result = await fetchTaskFileDiffs(taskId);
if (cancelled) return;
setFiles(result);
setSelectedFile((current) => {
if (result.length === 0) return null;
if (current) {
const match = result.find((file) => file.path === current.path && file.oldPath === current.oldPath);
if (match) return match;
}
return result[0] ?? null;
});
} catch (err) {
if (cancelled) return;
setFiles([]);
setSelectedFile(null);
setError(err instanceof Error ? err.message : "Failed to load changed files");
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [taskId, worktree, column]);
return { files, loading, error, selectedFile, setSelectedFile };
}

View File

@@ -3651,6 +3651,92 @@ describe("POST /tasks/:id/reject-plan", () => {
// --- Git Management route tests ---
// These are integration tests that run against the actual git repository
describe("GET /tasks/:id/file-diffs", () => {
let store: TaskStore;
let worktreeDir: string;
let testRoot: string;
beforeEach(() => {
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
worktreeDir = join(testRoot, "repo");
mkdirSync(worktreeDir, { recursive: true });
execFileSync("git", ["init", "-b", "main", worktreeDir]);
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
writeFileSync(join(worktreeDir, "README.md"), "base\n");
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
execFileSync("git", ["-C", worktreeDir, "add", "."]);
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
store = createMockStore({
getTask: vi.fn().mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-651",
worktree: worktreeDir,
baseBranch: "main",
}),
});
});
afterEach(() => {
rmSync(testRoot, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns changed files with statuses and diffs", async () => {
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
execFileSync("git", ["-C", worktreeDir, "rm", "renamed.txt"]);
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
]),
);
});
it("returns renamed files with oldPath", async () => {
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({ path: "renamed.txt", oldPath: "keep.txt", status: "renamed" }),
]);
});
it("returns empty array when worktree is missing", async () => {
store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
});
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when there are no changes", async () => {
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe("Git Management endpoints", () => {
let store: TaskStore;
let gitRepoDir: string;

View File

@@ -1062,6 +1062,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
console.debug("[planning:routes:registered]", planningRoutes);
}
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
const taskFileDiffsCache = new Map<
string,
{
files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>;
expiresAt: number;
}
>();
// Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
@@ -1841,6 +1848,114 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
if (!task.worktree || !existsSync(task.worktree)) {
res.json([]);
return;
}
const cached = taskFileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
return;
}
const baseBranch = task.baseBranch ?? "main";
type TaskFileDiff = { path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string };
let files: TaskFileDiff[] = [];
const parseNameStatus = (output: string): TaskFileDiff[] => {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split("\t");
const rawStatus = parts[0] ?? "M";
const statusCode = rawStatus[0];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
return {
path,
oldPath,
status: "renamed" as const,
diff: "",
};
}
const path = parts[1];
return {
path,
status:
statusCode === "A"
? ("added" as const)
: statusCode === "D"
? ("deleted" as const)
: ("modified" as const),
diff: "",
};
})
.filter((entry): entry is TaskFileDiff => Boolean(entry.path));
};
try {
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = output ? parseNameStatus(output) : [];
} catch {
const fallback = execSync("git diff --name-status HEAD", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = fallback ? parseNameStatus(fallback) : [];
}
if (files.length === 0) {
taskFileDiffsCache.set(task.id, {
files: [],
expiresAt: Date.now() + 10000,
});
res.json([]);
return;
}
const filesWithDiffs = files.map((file) => {
try {
const diff = execSync(`git diff ${baseBranch}...HEAD -- "${file.path.replace(/"/g, '\\"')}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
return { ...file, diff };
} catch {
return file;
}
});
taskFileDiffsCache.set(task.id, {
files: filesWithDiffs,
expiresAt: Date.now() + 10000,
});
res.json(filesWithDiffs);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/tasks/:id/diff
* Get detailed diff information for files modified during task execution.