feat: git add files from code review UI (#257)

* feat: add per-file git add from code review UI (#254)

Adds the ability to `git add` individual files directly from the code
review interface. Users can stage approved files then switch to the new
"Unstaged changes" diff view to see only remaining work.

- POST /api/git-add endpoint with worktree support
- gitAddFile/gitResetFile utilities in packages/server/git.ts
- "Staged changes" and "Unstaged changes" added to diff type dropdown
- useGitAdd hook encapsulating all staging state and API logic
- "Git Add" / "Added" toggle button in FileHeader (next to Viewed)
- Visual indicator (left border) for staged files in sidebar FileTree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: use green background tint for staged files instead of left border

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: support staged/unstaged diffs in worktrees and display stage errors

Adds "staged" and "unstaged" to worktree sub-type allowlists (server parser,
client parser, and runGitDiff switch), displays stageError in FileHeader,
and documents /api/git-add in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: clean up git add feature — fix stale closure, timeout leak, dedup validation

- useGitAdd: use ref for stagedFiles to stabilize stageFile callback,
  clear error timeout on reset, drop unnecessary useMemo
- App.tsx: wrap onFileViewed in useCallback
- FileTree: merge duplicate staged count conditionals
- review.ts: reuse exported validateFilePath for /api/file-content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-09 07:31:17 -07:00
committed by GitHub
parent 8201fe3537
commit d29dd08c13
10 changed files with 266 additions and 9 deletions
+34 -4
View File
@@ -10,7 +10,7 @@
*/
import { isRemoteSession, getServerPort } from "./remote";
import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff } from "./git";
import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff, gitAddFile, gitResetFile, parseWorktreeDiffType, validateFilePath } from "./git";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
@@ -182,12 +182,14 @@ export async function startReviewServer(
if (!filePath) {
return Response.json({ error: "Missing path" }, { status: 400 });
}
if (filePath.includes("..") || filePath.startsWith("/")) {
try { validateFilePath(filePath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
const oldPath = url.searchParams.get("oldPath") || undefined;
if (oldPath && (oldPath.includes("..") || oldPath.startsWith("/"))) {
return Response.json({ error: "Invalid path" }, { status: 400 });
if (oldPath) {
try { validateFilePath(oldPath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
}
const defaultBranch = gitContext?.defaultBranch || "main";
const result = await getFileContentsForDiff(
@@ -199,6 +201,34 @@ export async function startReviewServer(
return Response.json(result);
}
// API: Git add / reset (stage / unstage) a file
if (url.pathname === "/api/git-add" && req.method === "POST") {
try {
const body = (await req.json()) as { filePath: string; undo?: boolean };
if (!body.filePath) {
return Response.json({ error: "Missing filePath" }, { status: 400 });
}
// Determine cwd for worktree support
let cwd: string | undefined;
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) cwd = parsed.path;
}
if (body.undo) {
await gitResetFile(body.filePath, cwd);
} else {
await gitAddFile(body.filePath, cwd);
}
return Response.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to git add";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
return handleImage(req);