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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user