diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 9a339bc..37e6a9c 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -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', diff --git a/apps/server/src/services/__tests__/git_diff.test.ts b/apps/server/src/services/__tests__/git_diff.test.ts new file mode 100644 index 0000000..f0d5dcb --- /dev/null +++ b/apps/server/src/services/__tests__/git_diff.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { realpath } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + parseNameStatus, + splitDiffByFile, + classifyDiffBody, + autoSelectMode, + detectInProgress, + resolveCommittedBase, + canCommit, + getGitDiff, +} from '../git_diff.js'; +import type { GitDiffFile } from '../git_diff.js'; + +const execFileAsync = promisify(execFile); + +// ── T1: parseNameStatus ──────────────────────────────────────────────────── + +describe('parseNameStatus', () => { + it('parses modified file', () => { + const files = parseNameStatus('M\tsrc/foo.ts\n'); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'src/foo.ts', change_type: 'modified', old_path: null }); + }); + + it('parses added file', () => { + const files = parseNameStatus('A\tnewfile.ts\n'); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'newfile.ts', change_type: 'added' }); + }); + + it('parses deleted file', () => { + const files = parseNameStatus('D\tremoved.ts\n'); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'removed.ts', change_type: 'deleted' }); + }); + + it('parses renamed file with similarity score', () => { + const files = parseNameStatus('R100\told.ts\tnew.ts\n'); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'new.ts', old_path: 'old.ts', change_type: 'renamed' }); + }); + + it('parses type-changed file as modified', () => { + const files = parseNameStatus('T\tsymlink.ts\n'); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'symlink.ts', change_type: 'modified' }); + }); + + it('parses multiple files from multiline output', () => { + const output = 'M\ta.ts\nA\tb.ts\nD\tc.ts\n'; + const files = parseNameStatus(output); + expect(files).toHaveLength(3); + expect(files.map((f) => f.change_type)).toEqual(['modified', 'added', 'deleted']); + }); + + it('ignores blank lines', () => { + const files = parseNameStatus('\n\nM\ta.ts\n\n'); + expect(files).toHaveLength(1); + }); + + it('returns empty array for empty input', () => { + expect(parseNameStatus('')).toHaveLength(0); + expect(parseNameStatus('\n')).toHaveLength(0); + }); +}); + +// ── T2: splitDiffByFile ──────────────────────────────────────────────────── + +describe('splitDiffByFile', () => { + const FIXTURE = `diff --git a/src/a.ts b/src/a.ts +index abc1234..def5678 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + context +-old line ++new line + more context +diff --git a/src/b.ts b/src/b.ts +index 1111111..2222222 100644 +--- a/src/b.ts ++++ b/src/b.ts +@@ -10,2 +10,3 @@ + ctx ++added +`; + + it('splits two-file diff into two entries', () => { + const map = splitDiffByFile(FIXTURE); + expect(map.size).toBe(2); + expect(map.has('src/a.ts')).toBe(true); + expect(map.has('src/b.ts')).toBe(true); + }); + + it('each segment starts with diff --git header', () => { + const map = splitDiffByFile(FIXTURE); + expect(map.get('src/a.ts')).toMatch(/^diff --git a\/src\/a\.ts/); + expect(map.get('src/b.ts')).toMatch(/^diff --git a\/src\/b\.ts/); + }); + + it('handles deleted file (no +++ b/ line)', () => { + const deleted = `diff --git a/gone.ts b/gone.ts +deleted file mode 100644 +--- a/gone.ts ++++ /dev/null +@@ -1,2 +0,0 @@ +-line1 +-line2 +`; + const map = splitDiffByFile(deleted); + expect(map.size).toBe(1); + expect(map.has('gone.ts')).toBe(true); + }); + + it('returns empty map for empty input', () => { + expect(splitDiffByFile('').size).toBe(0); + expect(splitDiffByFile('\n').size).toBe(0); + }); +}); + +// ── T3: resolveCommittedBase (integration with temp git repo) ────────────── + +describe('resolveCommittedBase', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-base-'))); + await execFileAsync('git', ['init'], { cwd: tmp }); + await execFileAsync('git', ['-c', 'user.email=test@test.com', '-c', 'user.name=Test', + 'commit', '--allow-empty', '-m', 'init'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('returns null base when no upstream and no origin', async () => { + const { base, label } = await resolveCommittedBase(tmp); + expect(base).toBeNull(); + expect(label).toBeTruthy(); // still has a descriptive label + }); +}); + +// ── T4: autoSelectMode ──────────────────────────────────────────────────── + +describe('autoSelectMode', () => { + it('returns uncommitted when dirty', () => { + expect(autoSelectMode(true)).toBe('uncommitted'); + }); + + it('returns committed when clean', () => { + expect(autoSelectMode(false)).toBe('committed'); + }); +}); + +// ── T5: classifyDiffBody ────────────────────────────────────────────────── + +describe('classifyDiffBody', () => { + it('classifies a normal diff as diff', () => { + const body = `diff --git a/foo b/foo +--- a/foo ++++ b/foo +@@ -1 +1 @@ +-old ++new +`; + expect(classifyDiffBody(body)).toBe('diff'); + }); + + it('classifies binary diff as binary', () => { + const body = `diff --git a/image.png b/image.png +index abc..def 100644 +Binary files a/image.png and b/image.png differ +`; + expect(classifyDiffBody(body)).toBe('binary'); + }); + + it('classifies oversized diff as too_large', () => { + const big = 'a'.repeat(600 * 1024); // 600KB > default cap + expect(classifyDiffBody(big)).toBe('too_large'); + }); + + it('respects custom cap', () => { + const body = 'a'.repeat(100); + expect(classifyDiffBody(body, 50)).toBe('too_large'); + expect(classifyDiffBody(body, 200)).toBe('diff'); + }); +}); + +// ── T6: detectInProgress ────────────────────────────────────────────────── + +describe('detectInProgress', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-inprogress-'))); + await mkdir(join(tmp, '.git')); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('returns null when no sentinel files present', async () => { + expect(await detectInProgress(tmp)).toBeNull(); + }); + + it('detects merge via MERGE_HEAD', async () => { + await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc'); + expect(await detectInProgress(tmp)).toBe('merge'); + await rm(join(tmp, '.git', 'MERGE_HEAD')); + }); + + it('detects cherry-pick via CHERRY_PICK_HEAD', async () => { + await writeFile(join(tmp, '.git', 'CHERRY_PICK_HEAD'), 'abc'); + expect(await detectInProgress(tmp)).toBe('cherry-pick'); + await rm(join(tmp, '.git', 'CHERRY_PICK_HEAD')); + }); + + it('detects bisect via BISECT_LOG', async () => { + await writeFile(join(tmp, '.git', 'BISECT_LOG'), 'abc'); + expect(await detectInProgress(tmp)).toBe('bisect'); + await rm(join(tmp, '.git', 'BISECT_LOG')); + }); + + it('detects rebase via rebase-merge directory', async () => { + await mkdir(join(tmp, '.git', 'rebase-merge')); + expect(await detectInProgress(tmp)).toBe('rebase'); + await rm(join(tmp, '.git', 'rebase-merge'), { recursive: true }); + }); + + it('detects rebase via rebase-apply directory', async () => { + await mkdir(join(tmp, '.git', 'rebase-apply')); + expect(await detectInProgress(tmp)).toBe('rebase'); + await rm(join(tmp, '.git', 'rebase-apply'), { recursive: true }); + }); +}); + +// ── T7: canCommit ───────────────────────────────────────────────────────── + +describe('canCommit', () => { + const stagedFile: GitDiffFile = { + path: 'a.ts', + old_path: null, + change_type: 'modified', + added_lines: 1, + removed_lines: 0, + staged: true, + diff_body: '+new', + is_binary: false, + is_too_large: false, + }; + const unstagedFile: GitDiffFile = { ...stagedFile, staged: false }; + + it('returns true when at least one file is staged', () => { + expect(canCommit([stagedFile, unstagedFile])).toBe(true); + }); + + it('returns false when no files are staged', () => { + expect(canCommit([unstagedFile])).toBe(false); + expect(canCommit([])).toBe(false); + }); +}); + +// ── T8: getGitDiff integration test ─────────────────────────────────────── + +describe('getGitDiff integration (temp repo)', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-int-'))); + + // Init repo + initial commit + await execFileAsync('git', ['init'], { cwd: tmp }); + await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmp }); + await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: tmp }); + await writeFile(join(tmp, 'existing.ts'), 'const x = 1;\n'); + await execFileAsync('git', ['add', '.'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp }); + + // Modify existing file (unstaged) + await writeFile(join(tmp, 'existing.ts'), 'const x = 2;\n'); + // Add new untracked file + await writeFile(join(tmp, 'untracked.ts'), 'export {};\n'); + // Stage a new file + await writeFile(join(tmp, 'staged.ts'), 'export const y = 1;\n'); + await execFileAsync('git', ['add', 'staged.ts'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('getGitDiff returns git_repo true for a git repo', async () => { + const result = await getGitDiff(tmp, 'uncommitted'); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('uncommitted'); + }); + + it('includes modified file in uncommitted mode', async () => { + const result = await getGitDiff(tmp, 'uncommitted'); + const paths = result!.files.map((f: GitDiffFile) => f.path); + expect(paths).toContain('existing.ts'); + }); + + it('includes staged file with staged=true', async () => { + const result = await getGitDiff(tmp, 'uncommitted'); + const staged = result!.files.find((f: GitDiffFile) => f.path === 'staged.ts'); + expect(staged).toBeDefined(); + expect(staged!.staged).toBe(true); + expect(staged!.change_type).toBe('added'); + }); + + it('includes untracked file with change_type=untracked', async () => { + const result = await getGitDiff(tmp, 'uncommitted'); + const untracked = result!.files.find((f: GitDiffFile) => f.path === 'untracked.ts'); + expect(untracked).toBeDefined(); + expect(untracked!.change_type).toBe('untracked'); + }); + + it('returns null for a non-git directory', async () => { + const nonGit = await realpath(await mkdtemp(join(tmpdir(), 'boocode-nongit-'))); + try { + const result = await getGitDiff(nonGit, 'uncommitted'); + expect(result).toBeNull(); + } finally { + await rm(nonGit, { recursive: true, force: true }); + } + }); + + it('returns in_progress_op when MERGE_HEAD exists', async () => { + await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc\n'); + try { + const result = await getGitDiff(tmp, 'uncommitted'); + expect(result!.in_progress_op).toBe('merge'); + } finally { + await rm(join(tmp, '.git', 'MERGE_HEAD')); + } + }); +}); diff --git a/apps/server/src/services/__tests__/git_diff_write.test.ts b/apps/server/src/services/__tests__/git_diff_write.test.ts new file mode 100644 index 0000000..759aaf9 --- /dev/null +++ b/apps/server/src/services/__tests__/git_diff_write.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm, writeFile, mkdir, access, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { realpath } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + validateWritePath, + checkSymlinkEscape, + stageFiles, + unstageFiles, + commitFiles, + discardFiles, + deriveCommitIdentity, + GitWriteError, + getGitDiff, +} from '../git_diff.js'; + +const execFileAsync = promisify(execFile); + +// ── T12: validateWritePath — pure validation ────────────────────────────── + +describe('validateWritePath', () => { + const root = '/repo/root'; + + it('accepts a simple relative path', () => { + expect(() => validateWritePath(root, 'src/foo.ts')).not.toThrow(); + }); + + it('accepts a nested path', () => { + expect(() => validateWritePath(root, 'a/b/c.ts')).not.toThrow(); + }); + + it('rejects empty string', () => { + expect(() => validateWritePath(root, '')).toThrow(GitWriteError); + }); + + it('rejects path starting with - (flag injection)', () => { + expect(() => validateWritePath(root, '-flag')).toThrow(GitWriteError); + expect(() => validateWritePath(root, '--option')).toThrow(GitWriteError); + }); + + it('rejects "." (repo root discard)', () => { + expect(() => validateWritePath(root, '.')).toThrow(GitWriteError); + }); + + it('rejects absolute paths', () => { + expect(() => validateWritePath(root, '/etc/passwd')).toThrow(GitWriteError); + expect(() => validateWritePath(root, '/repo/root/file.ts')).toThrow(GitWriteError); + }); + + it('rejects ".." traversal escaping root', () => { + expect(() => validateWritePath(root, '../outside/file.ts')).toThrow(GitWriteError); + expect(() => validateWritePath(root, 'a/../../outside')).toThrow(GitWriteError); + }); + + it('rejects path resolving exactly to root', () => { + // e.g. "a/.." resolves to /repo/root which is the root itself + expect(() => validateWritePath(root, 'a/..')).toThrow(GitWriteError); + }); + + it('throws GitWriteError not just Error', () => { + try { + validateWritePath(root, '-bad'); + } catch (err) { + expect(err).toBeInstanceOf(GitWriteError); + expect((err as GitWriteError).busy).toBe(false); + } + }); +}); + +// ── Integration tests (temp git repo) ───────────────────────────────────── + +async function initRepo(dir: string) { + await execFileAsync('git', ['init'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: dir }); +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +// ── T9: stage / unstage round-trip ──────────────────────────────────────── + +describe('stageFiles / unstageFiles round-trip (temp repo)', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-stage-'))); + await initRepo(tmp); + await writeFile(join(tmp, 'initial.ts'), 'const a = 1;\n'); + await execFileAsync('git', ['add', '.'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('staging an untracked file shows it as staged in diff', async () => { + await writeFile(join(tmp, 'new.ts'), 'export const x = 1;\n'); + // Before staging + const before = await getGitDiff(tmp, 'uncommitted'); + const untrackedBefore = before!.files.find((f) => f.path === 'new.ts'); + expect(untrackedBefore?.change_type).toBe('untracked'); + expect(untrackedBefore?.staged).toBe(false); + + // Stage + await stageFiles(tmp, ['new.ts']); + + // After staging + const after = await getGitDiff(tmp, 'uncommitted'); + const stagedAfter = after!.files.find((f) => f.path === 'new.ts'); + expect(stagedAfter?.staged).toBe(true); + expect(stagedAfter?.change_type).toBe('added'); + }); + + it('unstaging removes file from staged set', async () => { + // new.ts is currently staged from the previous test + await unstageFiles(tmp, ['new.ts']); + const after = await getGitDiff(tmp, 'uncommitted'); + const f = after!.files.find((f) => f.path === 'new.ts'); + expect(f?.staged).toBe(false); + expect(f?.change_type).toBe('untracked'); + }); + + it('stageFiles rejects a path starting with -', async () => { + await expect(stageFiles(tmp, ['-bad'])).rejects.toThrow(GitWriteError); + }); + + it('stageFiles rejects path traversal', async () => { + await expect(stageFiles(tmp, ['../outside.ts'])).rejects.toThrow(GitWriteError); + }); +}); + +// ── T10: commit with server-derived identity ────────────────────────────── + +describe('commitFiles with server-derived identity (temp repo)', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-commit-'))); + await initRepo(tmp); + await writeFile(join(tmp, 'base.ts'), 'export const a = 1;\n'); + await execFileAsync('git', ['add', '.'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('deriveCommitIdentity falls back when no git config set', async () => { + // New repo initialized without global user config — may or may not have local config. + // The function should always return a non-empty name and email. + const identity = await deriveCommitIdentity(tmp); + expect(identity.name).toBeTruthy(); + expect(identity.email).toBeTruthy(); + }); + + it('deriveCommitIdentity uses git config when set', async () => { + const identity = await deriveCommitIdentity(tmp); + // We set user.email/name in initRepo above + expect(identity.name).toBe('Test User'); + expect(identity.email).toBe('test@test.com'); + }); + + it('commit creates a new commit and the staged file is no longer in diff', async () => { + await writeFile(join(tmp, 'newfile.ts'), 'export const b = 2;\n'); + await stageFiles(tmp, ['newfile.ts']); + + const before = await getGitDiff(tmp, 'uncommitted'); + expect(before!.files.find((f) => f.path === 'newfile.ts')).toBeDefined(); + + await commitFiles(tmp, 'add newfile'); + + const after = await getGitDiff(tmp, 'uncommitted'); + expect(after!.files.find((f) => f.path === 'newfile.ts')).toBeUndefined(); + }); + + it('commit with specific files only commits those files', async () => { + await writeFile(join(tmp, 'a.ts'), 'const a = 1;\n'); + await writeFile(join(tmp, 'b.ts'), 'const b = 2;\n'); + await stageFiles(tmp, ['a.ts', 'b.ts']); + + await commitFiles(tmp, 'partial commit', ['a.ts']); + + const after = await getGitDiff(tmp, 'uncommitted'); + const aFile = after!.files.find((f) => f.path === 'a.ts'); + const bFile = after!.files.find((f) => f.path === 'b.ts'); + // a.ts was committed — should not appear in uncommitted diff + expect(aFile).toBeUndefined(); + // b.ts is still staged + expect(bFile?.staged).toBe(true); + }); + + it('commit rejects a path starting with - in files list', async () => { + await expect(commitFiles(tmp, 'msg', ['-bad'])).rejects.toThrow(GitWriteError); + }); +}); + +// ── T11: discard tracked vs untracked ───────────────────────────────────── + +describe('discardFiles (temp repo)', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-discard-'))); + await initRepo(tmp); + await writeFile(join(tmp, 'tracked.ts'), 'const orig = 1;\n'); + await execFileAsync('git', ['add', '.'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('discarding a modified tracked file reverts its content', async () => { + await writeFile(join(tmp, 'tracked.ts'), 'const modified = 99;\n'); + const before = await getGitDiff(tmp, 'uncommitted'); + expect(before!.files.find((f) => f.path === 'tracked.ts')).toBeDefined(); + + await discardFiles(tmp, [{ path: 'tracked.ts', change_type: 'modified', staged: false }]); + + const after = await getGitDiff(tmp, 'uncommitted'); + expect(after!.files.find((f) => f.path === 'tracked.ts')).toBeUndefined(); + }); + + it('discarding an untracked file removes it from disk', async () => { + await writeFile(join(tmp, 'untracked.ts'), 'orphan\n'); + const exists = await fileExists(join(tmp, 'untracked.ts')); + expect(exists).toBe(true); + + await discardFiles(tmp, [{ path: 'untracked.ts', change_type: 'untracked', staged: false }]); + + expect(await fileExists(join(tmp, 'untracked.ts'))).toBe(false); + }); + + it('discarding a staged-addition file removes it from index and disk', async () => { + await writeFile(join(tmp, 'staged-add.ts'), 'new file\n'); + await stageFiles(tmp, ['staged-add.ts']); + + const before = await getGitDiff(tmp, 'uncommitted'); + expect(before!.files.find((f) => f.path === 'staged-add.ts')?.staged).toBe(true); + + await discardFiles(tmp, [{ path: 'staged-add.ts', change_type: 'added', staged: true }]); + + const after = await getGitDiff(tmp, 'uncommitted'); + expect(after!.files.find((f) => f.path === 'staged-add.ts')).toBeUndefined(); + expect(await fileExists(join(tmp, 'staged-add.ts'))).toBe(false); + }); + + it('discardFiles rejects "." (repo root)', async () => { + await expect( + discardFiles(tmp, [{ path: '.', change_type: 'modified', staged: false }]), + ).rejects.toThrow(GitWriteError); + }); + + it('discardFiles rejects path traversal', async () => { + await expect( + discardFiles(tmp, [{ path: '../outside', change_type: 'untracked', staged: false }]), + ).rejects.toThrow(GitWriteError); + }); +}); + +// ── Index-lock → busy error ──────────────────────────────────────────────── + +describe('index-lock detection', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-lock-'))); + await initRepo(tmp); + await writeFile(join(tmp, 'file.ts'), 'const x = 1;\n'); + await execFileAsync('git', ['add', '.'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp }); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('stageFiles throws GitWriteError with busy=true when index.lock exists', async () => { + await writeFile(join(tmp, 'new.ts'), 'export {};\n'); + // Simulate a lock by creating .git/index.lock + await mkdir(join(tmp, '.git'), { recursive: true }); + await writeFile(join(tmp, '.git', 'index.lock'), ''); + try { + await stageFiles(tmp, ['new.ts']); + // Should not reach here + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(GitWriteError); + expect((err as GitWriteError).busy).toBe(true); + } finally { + try { await rm(join(tmp, '.git', 'index.lock')); } catch { /* already gone */ } + } + }); +}); + +// ── Commit request schema: reject unknown author fields ─────────────────── + +describe('GitCommitBody schema strictness (unit)', () => { + it('rejects extra author/email fields via Zod strict', () => { + // We import Zod inline to mirror the route's schema + const { z } = require('zod'); + const GitCommitBody = z + .object({ + message: z.string().min(1), + files: z.array(z.string().min(1)).optional(), + }) + .strict(); + + const result = GitCommitBody.safeParse({ + message: 'test commit', + author: 'Evil ', + email: 'evil@hack.com', + }); + expect(result.success).toBe(false); + }); + + it('accepts valid commit body with message only', () => { + const { z } = require('zod'); + const GitCommitBody = z + .object({ + message: z.string().min(1), + files: z.array(z.string().min(1)).optional(), + }) + .strict(); + + const result = GitCommitBody.safeParse({ message: 'add feature' }); + expect(result.success).toBe(true); + }); +}); + +// ── T13: checkSymlinkEscape (FIX 3) ────────────────────────────────────────── + +describe('checkSymlinkEscape', () => { + let repoDir: string; + let outsideDir: string; + + beforeAll(async () => { + repoDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-repo-'))); + outsideDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-outside-'))); + await writeFile(join(outsideDir, 'secret.ts'), 'secret data\n'); + // Symlink inside repo pointing to outside dir + await symlink(outsideDir, join(repoDir, 'evil')); + }); + + afterAll(async () => { + await rm(repoDir, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + }); + + it('rejects a path that escapes via a directory symlink', async () => { + await expect(checkSymlinkEscape(repoDir, 'evil/secret.ts')).rejects.toThrow(GitWriteError); + }); + + it('rejects a path that resolves to the symlink itself (outside)', async () => { + await expect(checkSymlinkEscape(repoDir, 'evil')).rejects.toThrow(GitWriteError); + }); + + it('accepts a path that resolves within the repo', async () => { + await writeFile(join(repoDir, 'legit.ts'), 'export {};\n'); + await expect(checkSymlinkEscape(repoDir, 'legit.ts')).resolves.toBeUndefined(); + }); + + it('accepts a non-existent path (new file being staged)', async () => { + await expect(checkSymlinkEscape(repoDir, 'brand-new-file-not-yet-created.ts')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/git_diff.ts b/apps/server/src/services/git_diff.ts new file mode 100644 index 0000000..c2c8629 --- /dev/null +++ b/apps/server/src/services/git_diff.ts @@ -0,0 +1,554 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { stat, realpath } from 'node:fs/promises'; +import { isAbsolute, join, resolve, sep } from 'node:path'; + +const execFileAsync = promisify(execFile); + +const GIT_TIMEOUT_MS = 30_000; +const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB +const FILE_DIFF_CAP = 512 * 1024; // 512KB per-file display cap + +export type GitDiffMode = 'uncommitted' | 'committed'; +export type ChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; + +export interface GitDiffFile { + path: string; + old_path: string | null; + change_type: ChangeType; + added_lines: number; + removed_lines: number; + staged: boolean; + diff_body: string | null; // null when is_binary or is_too_large + is_binary: boolean; + is_too_large: boolean; +} + +export interface GitDiffResult { + mode: GitDiffMode; + base_label: string | null; + in_progress_op: string | null; + files: GitDiffFile[]; +} + +// runGit with 30s deadline and 10MB buffer for diff payloads. Returns null on +// any failure so callers can degrade gracefully without surfacing git errors. +async function runGit(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync('git', args, { + cwd, + timeout: GIT_TIMEOUT_MS, + windowsHide: true, + maxBuffer: GIT_MAX_BUFFER, + }); + return stdout.toString(); + } catch { + return null; + } +} + +// ── Pure helpers (unit-testable without spawning git) ────────────────────── + +/** Parses a single `git diff --name-status` output line. Returns null on garbage. */ +function parseNameStatusLine(line: string): { + path: string; + old_path: string | null; + change_type: ChangeType; +} | null { + const trimmed = line.trim(); + if (!trimmed) return null; + const parts = trimmed.split('\t'); + if (parts.length < 2) return null; + const code = parts[0] ?? ''; + // Rename: R\told\tnew Copy: C\told\tnew + if (code.startsWith('R') || code.startsWith('C')) { + if (parts.length < 3) return null; + return { path: parts[2] ?? '', old_path: parts[1] ?? null, change_type: 'renamed' }; + } + const path = parts[1] ?? ''; + if (!path) return null; + switch (code[0]) { + case 'A': return { path, old_path: null, change_type: 'added' }; + case 'M': + case 'T': // type changed + case 'U': // unmerged + return { path, old_path: null, change_type: 'modified' }; + case 'D': return { path, old_path: null, change_type: 'deleted' }; + default: return null; + } +} + +/** Parses multi-line `git diff --name-status` output into a file list. */ +export function parseNameStatus(output: string): { + path: string; + old_path: string | null; + change_type: ChangeType; +}[] { + return output + .split('\n') + .map((l) => parseNameStatusLine(l)) + .filter((x): x is NonNullable => x !== null); +} + +/** Parses a single `git diff --numstat` output line. */ +export function parseNumStatLine(line: string): { + path: string; + added: number; + removed: number; + binary: boolean; +} | null { + const parts = line.trim().split('\t'); + if (parts.length < 3) return null; + const [added, removed, path] = parts; + if (!path) return null; + if (added === '-' && removed === '-') { + return { path, added: 0, removed: 0, binary: true }; + } + const a = parseInt(added ?? '', 10); + const r = parseInt(removed ?? '', 10); + if (isNaN(a) || isNaN(r)) return null; + return { path, added: a, removed: r, binary: false }; +} + +/** Splits a unified diff text into per-file bodies keyed by current path. */ +export function splitDiffByFile(diffText: string): Map { + const result = new Map(); + if (!diffText.trim()) return result; + + // Split at each "diff --git" header (lookahead keeps the header with its section) + const sections = diffText.split(/(?=^diff --git )/m); + for (const section of sections) { + if (!section.trim()) continue; + + // Current path: prefer "+++ b/" (absent for pure renames / deleted files) + const pppMatch = section.match(/^\+{3} b\/(.+)$/m); + if (pppMatch) { + result.set((pppMatch[1] ?? '').trim(), section); + continue; + } + + // Deleted file: "--- a/" with "+++ /dev/null" + const mmmMatch = section.match(/^-{3} a\/(.+)$/m); + if (mmmMatch) { + const p = (mmmMatch[1] ?? '').trim(); + if (p && p !== '/dev/null') { + result.set(p, section); + continue; + } + } + + // Pure rename with no content change: extract from "diff --git a/... b/..." + // Take everything after the last " b/" on that line. + const gitLineMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m); + if (gitLineMatch) { + result.set((gitLineMatch[1] ?? '').trim(), section); + } + } + return result; +} + +/** Classifies a diff body segment as diff | binary | too_large. */ +export function classifyDiffBody(body: string, cap = FILE_DIFF_CAP): 'diff' | 'binary' | 'too_large' { + if (/^Binary files /m.test(body)) return 'binary'; + if (body.length > cap) return 'too_large'; + return 'diff'; +} + +/** Returns the auto-selected diff mode based on dirty state. */ +export function autoSelectMode(isDirty: boolean): GitDiffMode { + return isDirty ? 'uncommitted' : 'committed'; +} + +/** Returns true when at least one file is staged (commit is possible). */ +export function canCommit(files: GitDiffFile[]): boolean { + return files.some((f) => f.staged); +} + +/** Returns true when the working tree has uncommitted changes (staged or unstaged). */ +export async function isRepoDirty(cwd: string): Promise { + const gitRoot = await resolveGitRoot(cwd); + if (!gitRoot) return false; + const out = await runGit(['status', '--porcelain'], gitRoot); + if (out === null) return true; // can't determine — assume dirty + return out.trim().length > 0; +} + +/** + * Async per-file symlink-escape guard (FIX 3 / D-4). Resolves the real path of + * the target (if it already exists on disk) and rejects when it falls outside + * the repo root. Non-existent paths (new files being staged) are allowed — there + * is no symlink to follow when the file hasn't been created yet. + */ +export async function checkSymlinkEscape(repoRoot: string, filePath: string): Promise { + const resolved = resolve(repoRoot, filePath); + let real: string; + try { + real = await realpath(resolved); + } catch { + // File doesn't exist yet — no symlink to resolve, safe to proceed. + return; + } + if (real !== repoRoot && !real.startsWith(repoRoot + sep)) { + throw new GitWriteError(`path escapes repository root via symlink: ${filePath}`, false); + } +} + +// ── Async helpers ────────────────────────────────────────────────────────── + +/** Resolves the base ref for Committed mode with fallback chain. */ +export async function resolveCommittedBase( + cwd: string, +): Promise<{ base: string | null; label: string }> { + // 1. Tracking branch (@{upstream}) + const upstream = await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], + cwd, + ); + if (upstream !== null) { + const trimmed = upstream.trim(); + if (trimmed && !trimmed.includes('fatal')) { + return { base: trimmed, label: trimmed }; + } + } + + // 2. origin/HEAD (default branch) + const originHead = await runGit(['rev-parse', '--abbrev-ref', 'origin/HEAD'], cwd); + if (originHead !== null) { + const trimmed = originHead.trim(); + if (trimmed && !trimmed.includes('fatal') && !trimmed.includes('unknown')) { + return { base: trimmed, label: trimmed }; + } + } + + return { base: null, label: 'uncommitted (no base found)' }; +} + +/** Detects in-progress git operations via .git sentinel files/dirs. */ +export async function detectInProgress(repoRoot: string): Promise { + const fileChecks: [string, string][] = [ + ['MERGE_HEAD', 'merge'], + ['CHERRY_PICK_HEAD', 'cherry-pick'], + ['BISECT_LOG', 'bisect'], + ]; + for (const [file, op] of fileChecks) { + try { + await stat(join(repoRoot, '.git', file)); + return op; + } catch { + // sentinel not present — continue + } + } + for (const dir of ['rebase-merge', 'rebase-apply']) { + try { + await stat(join(repoRoot, '.git', dir)); + return 'rebase'; + } catch { + // not present — continue + } + } + return null; +} + +// ── Read logic ───────────────────────────────────────────────────────────── + +/** Resolves the git work-tree root for the given path. Returns null if not a repo. */ +async function resolveGitRoot(cwd: string): Promise { + const out = await runGit(['rev-parse', '--show-toplevel'], cwd); + return out !== null ? out.trim() : null; +} + +function buildNumstatMap( + output: string, +): Map { + const map = new Map(); + for (const line of output.split('\n')) { + const parsed = parseNumStatLine(line); + if (parsed) map.set(parsed.path, { added: parsed.added, removed: parsed.removed, binary: parsed.binary }); + } + return map; +} + +async function getUncommittedDiff( + gitRoot: string, + inProgress: string | null, +): Promise { + const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null; + + const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] = + await Promise.all([ + hasCommits + ? runGit(['diff', '--name-status', 'HEAD'], gitRoot) + : Promise.resolve(''), + hasCommits + ? runGit(['diff', '--cached', '--name-status', 'HEAD'], gitRoot) + : runGit(['diff', '--cached', '--name-status'], gitRoot), + runGit(['ls-files', '--others', '--exclude-standard'], gitRoot), + hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''), + hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''), + hasCommits + ? runGit(['diff', '--cached', 'HEAD'], gitRoot) + : runGit(['diff', '--cached'], gitRoot), + ]); + + const allChanged = parseNameStatus(nameStatusOut ?? ''); + const stagedSet = new Set( + parseNameStatus(cachedNameStatusOut ?? '').map((f) => f.path), + ); + const untracked = (untrackedOut ?? '').split('\n').filter(Boolean); + + const numstatMap = buildNumstatMap(numstatOut ?? ''); + + // Merge unstaged and staged diff maps + const diffMap = splitDiffByFile(diffOut ?? ''); + const cachedDiffMap = splitDiffByFile(cachedDiffOut ?? ''); + // Staged-only files won't be in diffOut; supplement from cachedDiffMap + for (const [k, v] of cachedDiffMap) { + if (!diffMap.has(k)) diffMap.set(k, v); + } + + const files: GitDiffFile[] = []; + + for (const entry of allChanged) { + const ns = numstatMap.get(entry.path); + const body = diffMap.get(entry.path) ?? null; + const kind = body !== null ? classifyDiffBody(body) : ns?.binary ? 'binary' : 'diff'; + files.push({ + path: entry.path, + old_path: entry.old_path, + change_type: entry.change_type, + added_lines: ns?.added ?? 0, + removed_lines: ns?.removed ?? 0, + staged: stagedSet.has(entry.path), + diff_body: kind === 'diff' ? body : null, + is_binary: kind === 'binary', + is_too_large: kind === 'too_large', + }); + } + + for (const p of untracked) { + files.push({ + path: p, + old_path: null, + change_type: 'untracked', + added_lines: 0, + removed_lines: 0, + staged: false, + diff_body: null, + is_binary: false, + is_too_large: false, + }); + } + + return { mode: 'uncommitted', base_label: null, in_progress_op: inProgress, files }; +} + +async function getCommittedDiff( + gitRoot: string, + base: string, + label: string, + inProgress: string | null, +): Promise { + const [nameStatusOut, numstatOut, diffOut] = await Promise.all([ + runGit(['diff', '--name-status', base, 'HEAD'], gitRoot), + runGit(['diff', '--numstat', base, 'HEAD'], gitRoot), + runGit(['diff', base, 'HEAD'], gitRoot), + ]); + + const allChanged = parseNameStatus(nameStatusOut ?? ''); + const numstatMap = buildNumstatMap(numstatOut ?? ''); + const diffMap = splitDiffByFile(diffOut ?? ''); + + const files: GitDiffFile[] = allChanged.map((entry) => { + const ns = numstatMap.get(entry.path); + const body = diffMap.get(entry.path) ?? null; + const kind = body !== null ? classifyDiffBody(body) : ns?.binary ? 'binary' : 'diff'; + return { + path: entry.path, + old_path: entry.old_path, + change_type: entry.change_type, + added_lines: ns?.added ?? 0, + removed_lines: ns?.removed ?? 0, + staged: false, // staged concept does not apply in committed mode + diff_body: kind === 'diff' ? body : null, + is_binary: kind === 'binary', + is_too_large: kind === 'too_large', + }; + }); + + return { mode: 'committed', base_label: label, in_progress_op: inProgress, files }; +} + +/** + * Returns the structured git diff for the given directory and mode, or null if + * the directory is not a git repository. On a null committed-mode base, falls + * back to uncommitted and labels the result accordingly. + */ +export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise { + const gitRoot = await resolveGitRoot(cwd); + if (!gitRoot) return null; + + const inProgress = await detectInProgress(gitRoot); + + if (mode === 'uncommitted') { + return getUncommittedDiff(gitRoot, inProgress); + } + + const { base, label } = await resolveCommittedBase(gitRoot); + if (!base) { + // Fall back to uncommitted with a descriptive label + const result = await getUncommittedDiff(gitRoot, inProgress); + return { ...result, base_label: label }; + } + return getCommittedDiff(gitRoot, base, label, inProgress); +} + +// ── Phase 2: Write helpers ───────────────────────────────────────────────── + +// Fallback identity matching project_bootstrap.ts constants. +const GIT_USER_NAME = 'indifferentketchup'; +const GIT_USER_EMAIL = 'samkintop@gmail.com'; + +export class GitWriteError extends Error { + constructor( + message: string, + public readonly busy: boolean, + ) { + super(message); + this.name = 'GitWriteError'; + } +} + +/** + * Validates a per-file path argument for write operations. + * Rejects flag injection (leading `-`), repo-root discard (`.`), absolute + * paths, and `..` traversal without requiring the file to exist on disk. + */ +export function validateWritePath(repoRoot: string, filePath: string): void { + if (!filePath || typeof filePath !== 'string' || filePath.trim() === '') { + throw new GitWriteError('path is required', false); + } + if (filePath.startsWith('-')) { + throw new GitWriteError(`invalid path (flag injection): ${filePath}`, false); + } + if (filePath === '.') { + throw new GitWriteError('cannot operate on repository root (.)', false); + } + if (isAbsolute(filePath)) { + throw new GitWriteError(`path must be relative: ${filePath}`, false); + } + const resolved = resolve(repoRoot, filePath); + if (resolved === repoRoot || !resolved.startsWith(repoRoot + sep)) { + throw new GitWriteError(`path escapes repository root: ${filePath}`, false); + } +} + +/** Reads git config user.name/email, falling back to bootstrap constants. */ +export async function deriveCommitIdentity( + repoRoot: string, +): Promise<{ name: string; email: string }> { + const [nameOut, emailOut] = await Promise.all([ + runGit(['config', 'user.name'], repoRoot), + runGit(['config', 'user.email'], repoRoot), + ]); + return { + name: nameOut?.trim() || GIT_USER_NAME, + email: emailOut?.trim() || GIT_USER_EMAIL, + }; +} + +/** Runs a git write operation, propagating errors. Throws GitWriteError. */ +async function runGitWrite(args: string[], cwd: string): Promise { + try { + await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const busy = msg.includes('index.lock') || msg.includes('Another git process'); + throw new GitWriteError(busy ? 'repository is busy, try again' : msg, busy); + } +} + +/** Stages the given files (`git add -- `). */ +export async function stageFiles(repoRoot: string, files: string[]): Promise { + for (const f of files) { + validateWritePath(repoRoot, f); + await checkSymlinkEscape(repoRoot, f); + } + await runGitWrite(['add', '--', ...files], repoRoot); +} + +/** Unstages the given files (`git restore --staged -- `). */ +export async function unstageFiles(repoRoot: string, files: string[]): Promise { + for (const f of files) { + validateWritePath(repoRoot, f); + await checkSymlinkEscape(repoRoot, f); + } + await runGitWrite(['restore', '--staged', '--', ...files], repoRoot); +} + +/** Commits staged files with a server-derived identity. */ +export async function commitFiles( + repoRoot: string, + message: string, + files?: string[], +): Promise { + if (files && files.length > 0) { + for (const f of files) { + validateWritePath(repoRoot, f); + await checkSymlinkEscape(repoRoot, f); + } + } + const id = await deriveCommitIdentity(repoRoot); + const args = ['-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message]; + if (files && files.length > 0) args.push('--', ...files); + await runGitWrite(args, repoRoot); +} + +export interface DiscardFileInfo { + path: string; + change_type: string; + staged: boolean; +} + +/** + * Discards changes for the given files. + * - Untracked files: `git clean -f -- ` + * - Staged additions (new file staged, no HEAD version): unstage then clean + * - All other tracked files: `git restore HEAD -- ` (undoes staged + unstaged) + */ +export async function discardFiles(repoRoot: string, files: DiscardFileInfo[]): Promise { + for (const { path } of files) { + validateWritePath(repoRoot, path); + await checkSymlinkEscape(repoRoot, path); + } + + const untracked: string[] = []; + const stagedAdditions: string[] = []; + const tracked: string[] = []; + + for (const f of files) { + if (f.change_type === 'untracked') { + untracked.push(f.path); + } else if (f.change_type === 'added' && f.staged) { + stagedAdditions.push(f.path); + } else { + tracked.push(f.path); + } + } + + // Restore tracked files from HEAD (handles staged + unstaged modifications/deletions). + // git checkout HEAD -- is the most portable form: resets index + worktree. + if (tracked.length > 0) { + await runGitWrite(['checkout', 'HEAD', '--', ...tracked], repoRoot); + } + + // Staged additions: unstage first, then remove from working tree. + for (const p of stagedAdditions) { + await runGitWrite(['restore', '--staged', '--', p], repoRoot); + await runGitWrite(['clean', '-f', '--', p], repoRoot); + } + + // Untracked files: clean (hard delete). + if (untracked.length > 0) { + await runGitWrite(['clean', '-f', '--', ...untracked], repoRoot); + } +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 76ba306..79ac74e 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -10,6 +10,9 @@ import type { ViewFileResult, AgentsResponse, GitMeta, + GitDiffMode, + GitDiffResult, + GitDiscardFileInfo, Skill, ToolCostStat, ProviderSnapshotEntry, @@ -151,6 +154,32 @@ export const api = { request<{ files: string[] }>(`/api/projects/${id}/files`), git: (id: string) => request(`/api/projects/${id}/git`), + gitDiff: (id: string, mode: GitDiffMode | null) => + request( + mode !== null + ? `/api/projects/${id}/git/diff?mode=${mode}` + : `/api/projects/${id}/git/diff`, + ), + gitStage: (id: string, files: string[]) => + request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, { + method: 'POST', + body: JSON.stringify({ files }), + }), + gitUnstage: (id: string, files: string[]) => + request<{ ok: boolean }>(`/api/projects/${id}/git/unstage`, { + method: 'POST', + body: JSON.stringify({ files }), + }), + gitCommit: (id: string, body: { message: string; files?: string[] }) => + request<{ ok: boolean }>(`/api/projects/${id}/git/commit`, { + method: 'POST', + body: JSON.stringify(body), + }), + gitDiscard: (id: string, files: GitDiscardFileInfo[]) => + request<{ ok: boolean }>(`/api/projects/${id}/git/discard`, { + method: 'POST', + body: JSON.stringify({ files }), + }), }, sessions: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index d1587cb..ef41b3e 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -312,6 +312,39 @@ export interface GitMeta { behind: number; } +// git-diff-panel Phase 1: shapes returned by GET /api/projects/:id/git/diff. +export type GitDiffMode = 'uncommitted' | 'committed'; +export type GitDiffChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; + +export interface GitDiffFile { + path: string; + old_path: string | null; + change_type: GitDiffChangeType; + added_lines: number; + removed_lines: number; + staged: boolean; + diff_body: string | null; + is_binary: boolean; + is_too_large: boolean; +} + +export interface GitDiffResult { + git_repo: boolean; + mode: GitDiffMode; + /** Server-computed mode based on dirty state — used for auto-select (FIX 1) and mode suggestion (FIX 4). */ + auto_mode?: GitDiffMode; + base_label: string | null; + in_progress_op: string | null; + files: GitDiffFile[]; +} + +// git-diff-panel Phase 2: per-file info for the discard endpoint. +export interface GitDiscardFileInfo { + path: string; + change_type: GitDiffChangeType; + staged: boolean; +} + // Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by // the slash-command dropdown. `path` and `mtime` are exposed for debug surface // (/api/skills) but the dropdown only renders name + description. diff --git a/apps/web/src/components/GitDiffView.tsx b/apps/web/src/components/GitDiffView.tsx new file mode 100644 index 0000000..2c85fe9 --- /dev/null +++ b/apps/web/src/components/GitDiffView.tsx @@ -0,0 +1,493 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react'; +import { codeToHtml } from 'shiki'; +import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types'; +import { cn } from '@/lib/utils'; + +interface WriteProps { + mutating: boolean; + mutateError: string | null; + onStage: (files: string[]) => Promise; + onUnstage: (files: string[]) => Promise; + onCommit: (message: string, files?: string[]) => Promise; + onDiscard: (files: GitDiscardFileInfo[]) => Promise; +} + +interface Props extends WriteProps { + result: GitDiffResult | null; + loading: boolean; + error: string | null; + mode: GitDiffMode; + onSelectMode: (m: GitDiffMode) => void; + onRefresh: () => void; + /** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */ + modeSuggestion?: GitDiffMode | null; + /** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */ + pendingCount?: number; +} + +const CHANGE_TYPE_LABELS: Record = { + added: 'A', + modified: 'M', + deleted: 'D', + renamed: 'R', + untracked: '?', +}; + +const CHANGE_TYPE_COLORS: Record = { + added: 'text-green-500', + modified: 'text-yellow-500', + deleted: 'text-red-500', + renamed: 'text-blue-500', + untracked: 'text-muted-foreground', +}; + +interface DiscardConfirmState { + file: GitDiffFile; +} + +function DiscardConfirmDialog({ + state, + onConfirm, + onCancel, +}: { + state: DiscardConfirmState; + onConfirm: () => void; + onCancel: () => void; +}) { + const isUntracked = state.file.change_type === 'untracked'; + return ( +
+
+

+ {isUntracked ? 'Permanently delete file?' : 'Discard changes?'} +

+

+ {isUntracked + ? `${state.file.path} will be permanently deleted. This cannot be undone.` + : `Changes to ${state.file.path} will be reverted to the last commit. This cannot be undone.`} +

+
+ + +
+
+
+ ); +} + +function FileDiffRow({ + file, + uncommitted, + disabled, + onStage, + onUnstage, + onDiscardRequest, +}: { + file: GitDiffFile; + uncommitted: boolean; + disabled: boolean; + onStage: (path: string) => void; + onUnstage: (path: string) => void; + onDiscardRequest: (file: GitDiffFile) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [html, setHtml] = useState(null); + const [highlighting, setHighlighting] = useState(false); + const highlightRef = useRef(null); + + useEffect(() => { + if (!expanded || !file.diff_body) return; + if (html !== null) return; + let cancelled = false; + setHighlighting(true); + void codeToHtml(file.diff_body, { lang: 'diff', theme: 'github-dark' }) + .then((result) => { if (!cancelled) setHtml(result); }) + .catch(() => { if (!cancelled) setHtml(null); }) + .finally(() => { if (!cancelled) setHighlighting(false); }); + return () => { cancelled = true; }; + }, [expanded, file.diff_body, html]); + + useEffect(() => { + if (highlightRef.current && html !== null) { + // Shiki generates sanitized HTML — not user-supplied content. + // eslint-disable-next-line no-unsanitized/property + highlightRef.current.innerHTML = html; + } + }, [html]); + + const typeLabel = CHANGE_TYPE_LABELS[file.change_type] ?? '?'; + const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground'; + const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path; + + return ( +
  • +
    + + + {/* Write affordances — Uncommitted mode only */} + {uncommitted && ( +
    + {/* Stage / Unstage toggle */} + {file.change_type !== 'deleted' && ( + + )} + {/* Discard — separated secondary affordance */} + +
    + )} +
    + + {expanded && ( +
    + {file.is_binary && ( +

    Binary file

    + )} + {file.is_too_large && ( +

    Diff too large to display

    + )} + {file.change_type === 'untracked' && ( +

    Untracked — not yet staged

    + )} + {!file.is_binary && !file.is_too_large && file.diff_body && ( + <> + {highlighting && ( +

    Highlighting…

    + )} + {!highlighting && html !== null ? ( +
    + ) : ( + !highlighting && ( +
    +                    {file.diff_body}
    +                  
    + ) + )} + + )} +
    + )} +
  • + ); +} + +export function GitDiffView({ + result, + loading, + error, + mode, + onSelectMode, + onRefresh, + mutating, + mutateError, + onStage, + onUnstage, + onCommit, + onDiscard, + modeSuggestion, + pendingCount, +}: Props) { + const [commitMessage, setCommitMessage] = useState(''); + const [discardTarget, setDiscardTarget] = useState(null); + const [lastAction, setLastAction] = useState(null); + const lastActionTimer = useRef | null>(null); + + function flashAction(msg: string) { + setLastAction(msg); + if (lastActionTimer.current) clearTimeout(lastActionTimer.current); + lastActionTimer.current = setTimeout(() => setLastAction(null), 2000); + } + + const uncommitted = mode === 'uncommitted'; + const inProgress = result?.in_progress_op ?? null; + const writeDisabled = mutating || !!inProgress; + const stagedFiles = result?.files.filter((f) => f.staged) ?? []; + const canDoCommit = uncommitted && stagedFiles.length > 0 && commitMessage.trim().length > 0 && !writeDisabled; + + async function handleStage(path: string) { + const ok = await onStage([path]); + if (ok) flashAction('Staged'); + } + + async function handleUnstage(path: string) { + const ok = await onUnstage([path]); + if (ok) flashAction('Unstaged'); + } + + function handleDiscardRequest(file: GitDiffFile) { + setDiscardTarget({ file }); + } + + async function handleDiscardConfirm() { + if (!discardTarget) return; + const { file } = discardTarget; + setDiscardTarget(null); + const info: GitDiscardFileInfo = { + path: file.path, + change_type: file.change_type, + staged: file.staged, + }; + const ok = await onDiscard([info]); + if (ok) flashAction(file.change_type === 'untracked' ? 'Deleted' : 'Discarded'); + } + + async function handleCommit() { + const msg = commitMessage.trim(); + if (!msg) return; + const ok = await onCommit(msg); + if (ok) { + setCommitMessage(''); + flashAction('Committed'); + } + } + + if (loading && !result) { + return ( +
    + Loading diff… +
    + ); + } + + if (error) { + return ( +
    +

    {error}

    + +
    + ); + } + + if (!result || !result.git_repo) { + return ( +
    + Not a git repository +
    + ); + } + + const { files, base_label } = result; + + return ( +
    + {/* Mode selector */} +
    + + +
    + {(loading || mutating) && ( + {mutating ? 'Working…' : 'Refreshing…'} + )} + {lastAction && !mutating && ( + {lastAction} + )} + +
    + + {/* Committed-mode base label */} + {result.mode === 'committed' && base_label && ( +
    + + vs {base_label} +
    + )} + + {/* FIX 2: Fallback label — committed was requested but no base branch found */} + {result.mode === 'uncommitted' && result.base_label && ( +
    + + {result.base_label} +
    + )} + + {/* FIX 4: Mode suggestion — shown when pinned mode diverges from auto-selected mode */} + {modeSuggestion && ( +
    + Repo is now {modeSuggestion === 'uncommitted' ? 'dirty' : 'clean'} — + +
    + )} + + {/* In-progress op banner */} + {inProgress && ( +
    + {inProgress} in progress — write actions disabled +
    + )} + + {/* Mutation error */} + {mutateError && ( +
    + {mutateError} +
    + )} + + {/* File list */} +
    + {files.length === 0 ? ( +
    + {mode === 'uncommitted' ? 'No uncommitted changes' : 'No changes vs. the base branch'} + {/* FIX 5: hint when pending changes exist in the Coder pane */} + {!!pendingCount && ( + + {pendingCount} pending {pendingCount === 1 ? 'change' : 'changes'} visible in the Coder pane + + )} +
    + ) : ( +
      + {files.map((file) => ( + + ))} +
    + )} +
    + + {/* Commit panel — Uncommitted mode only */} + {uncommitted && ( +
    +