feat: git diff panel (Files/Git tab in the file browser)
Adds a Git tab to the right-side file panel that shows the project repository's diff and lets the user stage, unstage, commit, and discard whole files in-session. Two comparison modes (Uncommitted vs HEAD, and the branch vs its base — upstream tracking branch else default branch), auto- selected by repo state on first open and pinned after explicit choice; per-file expand/collapse with lazy syntax-highlighted diffs, +/- stats, and binary/large-file placeholders. All git read and write logic lives in apps/server via a new git_diff service: argv-safe execFile only (never a shell), per-file paths validated repo-relative through pathGuard with a realpath symlink-escape check, server-derived commit identity (the request carries no author fields), and the write endpoints are deliberately absent from the assistant tool registry. Reads are bounded (30s deadline, 10MB); an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as "repository busy" and disables writes. The panel stays current via a client git_diff_refresh session event (no new wire contract) coalesced across tab open, mutations, turn completion, and pending-change apply. Discard is an irrecoverable hard-delete behind a plain confirm that distinguishes reverting a tracked file from deleting an untracked one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,18 @@ import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
|
||||
import { listDir, viewFile } from '../services/file_ops.js';
|
||||
import { getProjectFiles } from '../services/file_index.js';
|
||||
import { getGitMeta } from '../services/git_meta.js';
|
||||
import {
|
||||
getGitDiff,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
commitFiles,
|
||||
discardFiles,
|
||||
detectInProgress,
|
||||
isRepoDirty,
|
||||
autoSelectMode,
|
||||
GitWriteError,
|
||||
} from '../services/git_diff.js';
|
||||
import type { GitDiffMode } from '../services/git_diff.js';
|
||||
import {
|
||||
bootstrapProject,
|
||||
BootstrapNameError,
|
||||
@@ -453,6 +465,178 @@ export function registerProjectRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/projects/:id/git/diff?mode=uncommitted|committed
|
||||
// Returns the structured diff payload for the project repository. mode param
|
||||
// selects the comparison: uncommitted (working tree vs HEAD) or committed
|
||||
// (branch vs its upstream/default-branch base). When mode is absent the server
|
||||
// auto-selects based on dirty state (FIX 1: dirty → uncommitted, clean → committed).
|
||||
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
||||
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
||||
// Returns { git_repo: false } when the path is not a git repository.
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
||||
'/api/projects/:id/git/diff',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
const rawMode = req.query.mode;
|
||||
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (projectPath === null) {
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
let projectRoot: string;
|
||||
try {
|
||||
projectRoot = await resolveProjectRoot(projectPath);
|
||||
} catch (err) {
|
||||
if (err instanceof PathScopeError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Always detect dirty state: used for auto-select (FIX 1) and suggestion (FIX 4).
|
||||
const dirty = await isRepoDirty(projectRoot);
|
||||
const auto_mode = autoSelectMode(dirty);
|
||||
|
||||
const mode: GitDiffMode =
|
||||
rawMode === 'committed' ? 'committed' :
|
||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||
auto_mode; // no mode param → auto-select (FIX 1)
|
||||
|
||||
const result = await getGitDiff(projectRoot, mode);
|
||||
if (result === null) {
|
||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||
}
|
||||
return { git_repo: true, ...result, auto_mode };
|
||||
}
|
||||
);
|
||||
|
||||
// ── Git write routes (Phase 2) ─────────────────────────────────────────────
|
||||
// These are user UI actions — NOT registered in the assistant tool registry.
|
||||
// D-3: argv-safe runGit/execFile with -- separators (never shell strings).
|
||||
// D-4: per-file pathGuard validation via validateWritePath.
|
||||
// D-5: commit identity server-derived; request body .strict(), no author fields.
|
||||
// D-7: index-lock → 409; in-progress op → 409.
|
||||
// D-13: NOT in ALL_TOOLS.
|
||||
|
||||
const GitFilesBody = z.object({ files: z.array(z.string().min(1)).min(1) });
|
||||
|
||||
const GitCommitBody = z
|
||||
.object({
|
||||
message: z.string().min(1),
|
||||
files: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const GitDiscardBody = z.object({
|
||||
files: z.array(
|
||||
z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
change_type: z.string().min(1),
|
||||
staged: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).min(1),
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/git/stage — stage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/stage',
|
||||
async (req, reply) => {
|
||||
const body = GitFilesBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await stageFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/unstage — unstage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/unstage',
|
||||
async (req, reply) => {
|
||||
const body = GitFilesBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await unstageFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/commit — commit staged files (identity server-derived)
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/commit',
|
||||
async (req, reply) => {
|
||||
const body = GitCommitBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await commitFiles(root, body.data.message, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/discard — discard file changes (irrecoverable)
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/discard',
|
||||
async (req, reply) => {
|
||||
const body = GitDiscardBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await discardFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/projects/:id/files
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/files',
|
||||
|
||||
Reference in New Issue
Block a user