feat(FN-2391): add clickable per-file diffs in Git Manager

- Add a new /api/git/diff/file endpoint with path and staged query handling, including validation and untracked-file diff support
- Introduce fetchGitFileDiff API client wiring and update GitManagerModal to load diffs for selected staged/unstaged file rows
- Improve file-row UX with active/focus styling, keyboard activation, and guarded async diff loading/error states
- Expand dashboard route and modal tests to cover per-file diff behavior, query validation, and stage/unstage interaction isolation
This commit is contained in:
Fusion
2026-04-24 22:10:53 -07:00
committed by gsxdsm
parent bafad3d45a
commit c871a938c2
6 changed files with 414 additions and 69 deletions

View File

@@ -1596,6 +1596,14 @@ export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; p
return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId));
}
/** Fetch diff for a specific file in staged or unstaged mode */
export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> {
const params = new URLSearchParams();
params.set("path", path);
params.set("staged", String(staged));
return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId));
}
/** Fetch file changes (staged and unstaged) */
export function fetchFileChanges(projectId?: string): Promise<GitFileChange[]> {
return api<GitFileChange[]>(withProjectId("/git/changes", projectId));

View File

@@ -35,7 +35,7 @@ import {
unstageFiles,
createCommit,
discardChanges,
fetchUnstagedDiff,
fetchGitFileDiff,
fetchGitRemotesDetailed,
addGitRemote,
removeGitRemote,
@@ -186,6 +186,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [committing, setCommitting] = useState(false);
const [changeDiff, setChangeDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingChangeDiff, setLoadingChangeDiff] = useState(false);
const [changeDiffError, setChangeDiffError] = useState<string | null>(null);
const [selectedDiffTarget, setSelectedDiffTarget] = useState<{ file: string; staged: boolean } | null>(null);
const changeDiffRequestIdRef = useRef(0);
// ── Commits state
const [commits, setCommits] = useState<GitCommit[]>([]);
@@ -237,6 +240,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setStatus(statusData);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
break;
}
case "commits": {
@@ -313,6 +319,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const changes = await fetchFileChanges(projectId);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to stage files", "error");
}
@@ -325,6 +334,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const changes = await fetchFileChanges(projectId);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to unstage files", "error");
}
@@ -339,6 +351,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setFileChanges(changes);
setStatus(statusData);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to discard changes", "error");
}
@@ -356,6 +371,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId)]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to commit", "error");
} finally {
@@ -377,6 +395,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId)]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to commit", "error");
} finally {
@@ -384,15 +405,31 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
}
}, [commitMessage, fileChanges, addToast, projectId]);
const handleViewDiff = useCallback(async () => {
const handleSelectDiffFile = useCallback(async (file: string, staged: boolean) => {
setSelectedDiffTarget({ file, staged });
setLoadingChangeDiff(true);
setChangeDiffError(null);
const requestId = changeDiffRequestIdRef.current + 1;
changeDiffRequestIdRef.current = requestId;
try {
const diff = await fetchUnstagedDiff(projectId);
const diff = await fetchGitFileDiff(file, staged, projectId);
if (changeDiffRequestIdRef.current !== requestId) {
return;
}
setChangeDiff(diff);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load diff", "error");
if (changeDiffRequestIdRef.current !== requestId) {
return;
}
const errorMessage = getErrorMessage(err) || "Failed to load file diff";
setChangeDiff(null);
setChangeDiffError(errorMessage);
addToast(errorMessage, "error");
} finally {
setLoadingChangeDiff(false);
if (changeDiffRequestIdRef.current === requestId) {
setLoadingChangeDiff(false);
}
}
}, [addToast, projectId]);
@@ -755,9 +792,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
onStageFiles={handleStageFiles}
onUnstageFiles={handleUnstageFiles}
onDiscardChanges={handleDiscardChanges}
onViewDiff={handleViewDiff}
onSelectDiffFile={handleSelectDiffFile}
selectedDiffTarget={selectedDiffTarget}
changeDiff={changeDiff}
loadingChangeDiff={loadingChangeDiff}
changeDiffError={changeDiffError}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
onCommit={handleCommit}
@@ -938,9 +977,11 @@ function ChangesPanel({
onStageFiles,
onUnstageFiles,
onDiscardChanges,
onViewDiff,
onSelectDiffFile,
selectedDiffTarget,
changeDiff,
loadingChangeDiff,
changeDiffError,
commitMessage,
setCommitMessage,
onCommit,
@@ -955,9 +996,11 @@ function ChangesPanel({
onStageFiles: (files: string[]) => void;
onUnstageFiles: (files: string[]) => void;
onDiscardChanges: (files: string[]) => void;
onViewDiff: () => void;
onSelectDiffFile: (file: string, staged: boolean) => void;
selectedDiffTarget: { file: string; staged: boolean } | null;
changeDiff: { stat: string; patch: string } | null;
loadingChangeDiff: boolean;
changeDiffError: string | null;
commitMessage: string;
setCommitMessage: (msg: string) => void;
onCommit: (e: React.FormEvent) => void;
@@ -1020,27 +1063,45 @@ function ChangesPanel({
{unstagedFiles.length === 0 ? (
<div className="gm-empty">No unstaged changes</div>
) : (
unstagedFiles.map((f) => (
<div key={`unstaged:${f.file}`} className="gm-file-item">
<label className="gm-file-checkbox">
<input
type="checkbox"
checked={selectedFiles.has(`unstaged:${f.file}`)}
onChange={() => toggleFileSelection(`unstaged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}>{f.file}</span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={() => onStageFiles([f.file])}
title="Stage file"
unstagedFiles.map((f) => {
const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === false;
return (
<div
key={`unstaged:${f.file}`}
className={`gm-file-item${isActive ? " active" : ""}`}
onClick={() => onSelectDiffFile(f.file, false)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectDiffFile(f.file, false);
}
}}
>
<Plus size={12} />
</button>
</div>
))
<label className="gm-file-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedFiles.has(`unstaged:${f.file}`)}
onChange={() => toggleFileSelection(`unstaged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}>{f.file}</span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={(e) => {
e.stopPropagation();
onStageFiles([f.file]);
}}
title="Stage file"
>
<Plus size={12} />
</button>
</div>
);
})
)}
</div>
</div>
@@ -1074,43 +1135,69 @@ function ChangesPanel({
{stagedFiles.length === 0 ? (
<div className="gm-empty">No staged changes</div>
) : (
stagedFiles.map((f) => (
<div key={`staged:${f.file}`} className="gm-file-item staged">
<label className="gm-file-checkbox">
<input
type="checkbox"
checked={selectedFiles.has(`staged:${f.file}`)}
onChange={() => toggleFileSelection(`staged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}>{f.file}</span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={() => onUnstageFiles([f.file])}
title="Unstage file"
stagedFiles.map((f) => {
const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === true;
return (
<div
key={`staged:${f.file}`}
className={`gm-file-item staged${isActive ? " active" : ""}`}
onClick={() => onSelectDiffFile(f.file, true)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectDiffFile(f.file, true);
}
}}
>
<X size={12} />
</button>
</div>
))
<label className="gm-file-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedFiles.has(`staged:${f.file}`)}
onChange={() => toggleFileSelection(`staged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}>{f.file}</span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={(e) => {
e.stopPropagation();
onUnstageFiles([f.file]);
}}
title="Unstage file"
>
<X size={12} />
</button>
</div>
);
})
)}
</div>
</div>
{/* Diff Viewer */}
{unstagedFiles.length > 0 && (
{(selectedDiffTarget || loadingChangeDiff || changeDiff || changeDiffError) && (
<div className="gm-diff-section">
<button
className="btn btn-sm"
onClick={onViewDiff}
disabled={loadingChangeDiff}
>
{loadingChangeDiff ? <Loader2 size={14} className="spin" /> : <FileDiff size={14} />}
View Diff
</button>
{changeDiff && (
{selectedDiffTarget && (
<div className="gm-diff-target">
<FileDiff size={14} />
<span>{selectedDiffTarget.staged ? "Staged" : "Unstaged"} diff: </span>
<code>{selectedDiffTarget.file}</code>
</div>
)}
{loadingChangeDiff && (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
)}
{changeDiffError && !loadingChangeDiff && (
<div className="gm-diff-error">{changeDiffError}</div>
)}
{changeDiff && !loadingChangeDiff && (
<div className="gm-diff-viewer">
{changeDiff.stat && <pre className="gm-diff-stat">{changeDiff.stat}</pre>}
<pre className="gm-diff-patch">{changeDiff.patch}</pre>

View File

@@ -2019,6 +2019,7 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
font-size: 13px;
transition: background var(--transition-fast);
cursor: pointer;
}
.gm-file-item:last-child {
@@ -2033,6 +2034,15 @@
background: rgba(63, 185, 80, 0.03);
}
.gm-file-item.active {
background: var(--card-hover);
}
.gm-file-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.gm-file-checkbox {
display: flex;
align-items: center;
@@ -2105,6 +2115,20 @@
gap: var(--space-sm);
}
.gm-diff-target {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.gm-diff-target code {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
}
.changed-files-diff-section {
height: 100%;
}

View File

@@ -23,7 +23,7 @@ vi.mock("../../api", async () => {
applyStash: vi.fn(),
dropStash: vi.fn(),
fetchFileChanges: vi.fn(),
fetchUnstagedDiff: vi.fn(),
fetchGitFileDiff: vi.fn(),
stageFiles: vi.fn(),
unstageFiles: vi.fn(),
createCommit: vi.fn(),
@@ -56,7 +56,7 @@ import {
applyStash,
dropStash,
fetchFileChanges,
fetchUnstagedDiff,
fetchGitFileDiff,
stageFiles,
unstageFiles,
createCommit,
@@ -156,7 +156,7 @@ describe("GitManagerModal", () => {
{ file: "src/app.ts", status: "modified", staged: false },
{ file: "src/index.ts", status: "added", staged: true },
]);
(fetchUnstagedDiff as any).mockResolvedValue({
(fetchGitFileDiff as any).mockResolvedValue({
stat: " src/app.ts | 5 ++---",
patch: "diff --git a/src/app.ts b/src/app.ts\n-old\n+new",
});
@@ -422,7 +422,7 @@ describe("GitManagerModal", () => {
});
});
it("views diff of unstaged changes", async () => {
it("fetches unstaged file diff when clicking an unstaged file", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
@@ -430,12 +430,86 @@ describe("GitManagerModal", () => {
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("View Diff")).toBeInTheDocument();
expect(screen.getByText("src/app.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("View Diff"));
await user.click(screen.getByText("src/app.ts"));
await waitFor(() => {
expect(fetchUnstagedDiff).toHaveBeenCalled();
expectLatestCallStartsWith(fetchGitFileDiff as any, "src/app.ts", false);
});
});
it("fetches staged file diff when clicking a staged file", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/index.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("src/index.ts"));
await waitFor(() => {
expectLatestCallStartsWith(fetchGitFileDiff as any, "src/index.ts", true);
});
});
it("stage action button still stages file without triggering diff fetch", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const stageButton = await screen.findByTitle("Stage file");
await user.click(stageButton);
await waitFor(() => {
expectLatestCallStartsWith(stageFiles as any, ["src/app.ts"]);
expect(fetchGitFileDiff).not.toHaveBeenCalled();
});
});
it("unstage action button still unstages file without triggering diff fetch", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const unstageButton = await screen.findByTitle("Unstage file");
await user.click(unstageButton);
await waitFor(() => {
expectLatestCallStartsWith(unstageFiles as any, ["src/index.ts"]);
expect(fetchGitFileDiff).not.toHaveBeenCalled();
});
});
it("renders fetched file diff patch content", async () => {
const user = userEvent.setup();
(fetchGitFileDiff as any).mockResolvedValue({
stat: " src/app.ts | 2 +\\-",
patch: "diff --git a/src/app.ts b/src/app.ts\\n+line",
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("src/app.ts"));
await waitFor(() => {
const patchBlock = document.querySelector(".gm-diff-patch");
expect(patchBlock?.textContent).toContain("diff --git a/src/app.ts b/src/app.ts");
expect(patchBlock?.textContent).toContain("+line");
});
});

View File

@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach }
import express from "express";
import http from "node:http";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
@@ -7328,6 +7328,71 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/diff/file", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
};
beforeEach(() => {
resetGitRepo();
});
afterEach(() => {
resetGitRepo();
});
it("returns unstaged diff for a specific file", async () => {
const readmePath = join(gitRepoDir, "README.md");
const original = readFileSync(readmePath, "utf-8");
const marker = `\nunstaged-diff-${Date.now()}\n`;
writeFileSync(readmePath, `${original}${marker}`);
const res = await GET(buildApp(), "/api/git/diff/file?path=README.md&staged=false");
expect(res.status).toBe(200);
expect(res.body.patch).toContain(marker.trim());
expect(res.body.patch).toContain("diff --git a/README.md b/README.md");
});
it("returns synthetic unstaged diff for untracked files", async () => {
const untrackedFile = `untracked-${Date.now()}.txt`;
const untrackedPath = join(gitRepoDir, untrackedFile);
writeFileSync(untrackedPath, "hello untracked\n");
const res = await GET(buildApp(), `/api/git/diff/file?path=${encodeURIComponent(untrackedFile)}&staged=false`);
expect(res.status).toBe(200);
expect(res.body.patch).toContain(`diff --git a/${untrackedFile} b/${untrackedFile}`);
expect(res.body.patch).toContain("hello untracked");
});
it("returns staged diff for a specific file", async () => {
const readmePath = join(gitRepoDir, "README.md");
const original = readFileSync(readmePath, "utf-8");
const marker = `\nstaged-diff-${Date.now()}\n`;
writeFileSync(readmePath, `${original}${marker}`);
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"], { stdio: "pipe" });
const res = await GET(buildApp(), "/api/git/diff/file?path=README.md&staged=true");
expect(res.status).toBe(200);
expect(res.body.patch).toContain(marker.trim());
expect(res.body.patch).toContain("diff --git a/README.md b/README.md");
});
it("returns 400 for missing or invalid query params", async () => {
const missingPath = await GET(buildApp(), "/api/git/diff/file?staged=false");
expect(missingPath.status).toBe(400);
expect(missingPath.body.error).toContain("path query parameter is required");
const invalidStaged = await GET(buildApp(), "/api/git/diff/file?path=README.md&staged=maybe");
expect(invalidStaged.status).toBe(400);
expect(invalidStaged.body.error).toContain("staged query parameter must be 'true' or 'false'");
});
});
describe("GET /git/commits/ahead", () => {
it("returns commits ahead of upstream", async () => {
const res = await GET(buildApp(), "/api/git/commits/ahead");

View File

@@ -1745,13 +1745,64 @@ async function getGitWorkingDiff(cwd?: string): Promise<{ stat: string; patch: s
}
}
/**
* Validate a file path passed to git commands.
*/
function isValidGitFilePath(filePath: string): boolean {
if (!filePath || !filePath.trim()) return false;
if (filePath.startsWith("-")) return false;
if (isAbsolute(filePath)) return false;
if (filePath.includes("\0")) return false;
if (filePath.includes("..")) return false;
if (/[;&|`$(){}[\]\r\n]/.test(filePath)) return false;
return true;
}
async function runNoIndexDiff(args: string[], cwd?: string): Promise<string> {
try {
return await runGitCommand(args, cwd, 10000);
} catch (error) {
const commandError = error as NodeJS.ErrnoException & { stdout?: string };
if (typeof commandError.stdout === "string") {
return commandError.stdout;
}
throw error;
}
}
/**
* Get staged or unstaged diff for a specific file path.
*/
async function getGitFileDiff(filePath: string, staged: boolean, cwd?: string): Promise<{ stat: string; patch: string }> {
if (!isValidGitFilePath(filePath)) {
throw new Error(`Invalid file path: ${filePath}`);
}
if (staged) {
const stat = (await runGitCommand(["diff", "--cached", "--stat", "--", filePath], cwd, 10000)).trim();
const patch = await runGitCommand(["diff", "--cached", "--", filePath], cwd, 10000);
return { stat, patch };
}
const untracked = (await runGitCommand(["ls-files", "--others", "--exclude-standard", "--", filePath], cwd, 5000)).trim();
if (untracked === filePath) {
const stat = (await runNoIndexDiff(["diff", "--no-index", "--stat", "/dev/null", filePath], cwd)).trim();
const patch = await runNoIndexDiff(["diff", "--no-index", "/dev/null", filePath], cwd);
return { stat, patch };
}
const stat = (await runGitCommand(["diff", "--stat", "--", filePath], cwd, 10000)).trim();
const patch = await runGitCommand(["diff", "--", filePath], cwd, 10000);
return { stat, patch };
}
/**
* Stage specific files.
*/
async function stageGitFiles(files: string[], cwd?: string): Promise<string[]> {
if (!files.length) throw new Error("No files specified");
for (const f of files) {
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
if (!isValidGitFilePath(f)) {
throw new Error(`Invalid file path: ${f}`);
}
}
@@ -1765,7 +1816,7 @@ async function stageGitFiles(files: string[], cwd?: string): Promise<string[]> {
async function unstageGitFiles(files: string[], cwd?: string): Promise<string[]> {
if (!files.length) throw new Error("No files specified");
for (const f of files) {
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
if (!isValidGitFilePath(f)) {
throw new Error(`Invalid file path: ${f}`);
}
}
@@ -1791,7 +1842,7 @@ async function createGitCommit(message: string, cwd?: string): Promise<{ hash: s
async function discardGitChanges(files: string[], cwd?: string): Promise<string[]> {
if (!files.length) throw new Error("No files specified");
for (const f of files) {
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
if (!isValidGitFilePath(f)) {
throw new Error(`Invalid file path: ${f}`);
}
}
@@ -6417,6 +6468,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/git/diff/file
* Returns staged or unstaged diff for a specific file.
* Query: path=<file-path>&staged=true|false
*/
router.get("/git/diff/file", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
const rawPath = req.query.path;
const rawStaged = req.query.staged;
if (typeof rawPath !== "string" || !rawPath.trim()) {
throw badRequest("path query parameter is required");
}
if (rawStaged !== "true" && rawStaged !== "false") {
throw badRequest("staged query parameter must be 'true' or 'false'");
}
if (!isValidGitFilePath(rawPath)) {
throw badRequest(`Invalid file path: ${rawPath}`);
}
const diff = await getGitFileDiff(rawPath, rawStaged === "true", rootDir);
res.json(diff);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/git/changes
* Returns file changes (staged and unstaged).