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',
|
||||
|
||||
346
apps/server/src/services/__tests__/git_diff.test.ts
Normal file
346
apps/server/src/services/__tests__/git_diff.test.ts
Normal file
@@ -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'));
|
||||
}
|
||||
});
|
||||
});
|
||||
379
apps/server/src/services/__tests__/git_diff_write.test.ts
Normal file
379
apps/server/src/services/__tests__/git_diff_write.test.ts
Normal file
@@ -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<boolean> {
|
||||
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 <evil@hack.com>',
|
||||
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();
|
||||
});
|
||||
});
|
||||
554
apps/server/src/services/git_diff.ts
Normal file
554
apps/server/src/services/git_diff.ts
Normal file
@@ -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<string | null> {
|
||||
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<score>\told\tnew Copy: C<score>\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<typeof x> => 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<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
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/<path>" (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/<path>" 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<boolean> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
const out = await runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
return out !== null ? out.trim() : null;
|
||||
}
|
||||
|
||||
function buildNumstatMap(
|
||||
output: string,
|
||||
): Map<string, { added: number; removed: number; binary: boolean }> {
|
||||
const map = new Map<string, { added: number; removed: number; binary: boolean }>();
|
||||
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<GitDiffResult> {
|
||||
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<GitDiffResult> {
|
||||
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<GitDiffResult | null> {
|
||||
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<void> {
|
||||
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 -- <files>`). */
|
||||
export async function stageFiles(repoRoot: string, files: string[]): Promise<void> {
|
||||
for (const f of files) {
|
||||
validateWritePath(repoRoot, f);
|
||||
await checkSymlinkEscape(repoRoot, f);
|
||||
}
|
||||
await runGitWrite(['add', '--', ...files], repoRoot);
|
||||
}
|
||||
|
||||
/** Unstages the given files (`git restore --staged -- <files>`). */
|
||||
export async function unstageFiles(repoRoot: string, files: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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 -- <path>`
|
||||
* - Staged additions (new file staged, no HEAD version): unstage then clean
|
||||
* - All other tracked files: `git restore HEAD -- <path>` (undoes staged + unstaged)
|
||||
*/
|
||||
export async function discardFiles(repoRoot: string, files: DiscardFileInfo[]): Promise<void> {
|
||||
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 -- <file> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<GitMeta>(`/api/projects/${id}/git`),
|
||||
gitDiff: (id: string, mode: GitDiffMode | null) =>
|
||||
request<GitDiffResult>(
|
||||
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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
493
apps/web/src/components/GitDiffView.tsx
Normal file
493
apps/web/src/components/GitDiffView.tsx
Normal file
@@ -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<boolean>;
|
||||
onUnstage: (files: string[]) => Promise<boolean>;
|
||||
onCommit: (message: string, files?: string[]) => Promise<boolean>;
|
||||
onDiscard: (files: GitDiscardFileInfo[]) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
added: 'A',
|
||||
modified: 'M',
|
||||
deleted: 'D',
|
||||
renamed: 'R',
|
||||
untracked: '?',
|
||||
};
|
||||
|
||||
const CHANGE_TYPE_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||||
>
|
||||
<div className="bg-popover border rounded-lg shadow-lg max-w-sm w-full p-4 flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
{isUntracked ? 'Permanently delete file?' : 'Discard changes?'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.`}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs px-3 py-1.5 rounded border hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="text-xs px-3 py-1.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
{isUntracked ? 'Delete' : 'Discard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(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 (
|
||||
<li className="border-b border-border/30 last:border-0">
|
||||
<div className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={10} className="shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight size={10} className="shrink-0 text-muted-foreground" />}
|
||||
<span className={cn('font-mono font-bold w-3 shrink-0', typeColor)}>{typeLabel}</span>
|
||||
<span className="truncate flex-1">{displayPath}</span>
|
||||
{(file.added_lines > 0 || file.removed_lines > 0) && (
|
||||
<span className="shrink-0 text-muted-foreground/70 font-mono text-[10px]">
|
||||
{file.added_lines > 0 && <span className="text-green-500">+{file.added_lines}</span>}
|
||||
{file.added_lines > 0 && file.removed_lines > 0 && <span className="mx-0.5">/</span>}
|
||||
{file.removed_lines > 0 && <span className="text-red-500">-{file.removed_lines}</span>}
|
||||
</span>
|
||||
)}
|
||||
{file.staged && (
|
||||
<span className="shrink-0 text-[10px] bg-blue-500/15 text-blue-400 px-1 rounded">staged</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Write affordances — Uncommitted mode only */}
|
||||
{uncommitted && (
|
||||
<div className="flex items-center gap-0.5 px-1 shrink-0">
|
||||
{/* Stage / Unstage toggle */}
|
||||
{file.change_type !== 'deleted' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => file.staged ? onUnstage(file.path) : onStage(file.path)}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded border border-border/50 hover:bg-muted disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={file.staged ? 'Unstage' : 'Stage'}
|
||||
>
|
||||
{file.staged ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
{/* Discard — separated secondary affordance */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onDiscardRequest(file)}
|
||||
className="p-1 rounded hover:bg-destructive/15 hover:text-destructive text-muted-foreground/50 disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={file.change_type === 'untracked' ? 'Delete file' : 'Discard changes'}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-2 pb-2">
|
||||
{file.is_binary && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>
|
||||
)}
|
||||
{file.is_too_large && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>
|
||||
)}
|
||||
{file.change_type === 'untracked' && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
||||
)}
|
||||
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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<DiscardConfirmState | null>(null);
|
||||
const [lastAction, setLastAction] = useState<string | null>(null);
|
||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading diff…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 max-md:min-h-[44px]"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result || !result.git_repo) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-4 text-center">
|
||||
Not a git repository
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { files, base_label } = result;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Mode selector */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode('uncommitted')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
mode === 'uncommitted'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Uncommitted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode('committed')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
mode === 'committed'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Committed
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{(loading || mutating) && (
|
||||
<span className="text-[10px] text-muted-foreground">{mutating ? 'Working…' : 'Refreshing…'}</span>
|
||||
)}
|
||||
{lastAction && !mutating && (
|
||||
<span className="text-[10px] text-green-500">{lastAction}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading || mutating}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Refresh diff"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Committed-mode base label */}
|
||||
{result.mode === 'committed' && base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
||||
<GitBranch size={10} />
|
||||
<span className="truncate">vs {base_label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FIX 2: Fallback label — committed was requested but no base branch found */}
|
||||
{result.mode === 'uncommitted' && result.base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-amber-600 dark:text-amber-400 border-b flex items-center gap-1 shrink-0">
|
||||
<GitBranch size={10} />
|
||||
<span className="truncate">{result.base_label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FIX 4: Mode suggestion — shown when pinned mode diverges from auto-selected mode */}
|
||||
{modeSuggestion && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b shrink-0 flex items-center gap-1">
|
||||
<span>Repo is now {modeSuggestion === 'uncommitted' ? 'dirty' : 'clean'} —</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode(modeSuggestion)}
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
switch to {modeSuggestion === 'uncommitted' ? 'Uncommitted' : 'Committed'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In-progress op banner */}
|
||||
{inProgress && (
|
||||
<div className="px-2 py-1 text-[10px] text-yellow-500 bg-yellow-500/10 border-b shrink-0">
|
||||
{inProgress} in progress — write actions disabled
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mutation error */}
|
||||
{mutateError && (
|
||||
<div className="px-2 py-1 text-[10px] text-destructive bg-destructive/10 border-b shrink-0 truncate">
|
||||
{mutateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-8 text-xs text-muted-foreground text-center gap-1.5">
|
||||
<span>{mode === 'uncommitted' ? 'No uncommitted changes' : 'No changes vs. the base branch'}</span>
|
||||
{/* FIX 5: hint when pending changes exist in the Coder pane */}
|
||||
{!!pendingCount && (
|
||||
<span className="text-[10px]">
|
||||
{pendingCount} pending {pendingCount === 1 ? 'change' : 'changes'} visible in the Coder pane
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-none">
|
||||
{files.map((file) => (
|
||||
<FileDiffRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
uncommitted={uncommitted}
|
||||
disabled={writeDisabled}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscardRequest={handleDiscardRequest}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commit panel — Uncommitted mode only */}
|
||||
{uncommitted && (
|
||||
<div className="shrink-0 border-t px-2 py-2 flex flex-col gap-1.5">
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
disabled={writeDisabled}
|
||||
placeholder="Commit message…"
|
||||
rows={2}
|
||||
className="w-full text-xs rounded border bg-background px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-40 placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground flex-1">
|
||||
{stagedFiles.length > 0
|
||||
? `${stagedFiles.length} file${stagedFiles.length > 1 ? 's' : ''} staged`
|
||||
: 'No files staged'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canDoCommit}
|
||||
onClick={handleCommit}
|
||||
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 max-md:min-h-[44px]"
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discard confirmation dialog */}
|
||||
{discardTarget && (
|
||||
<DiscardConfirmDialog
|
||||
state={discardTarget}
|
||||
onConfirm={handleDiscardConfirm}
|
||||
onCancel={() => setDiscardTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { useProjectGit } from '@/hooks/useProjectGit';
|
||||
import { useGitDiff } from '@/hooks/useGitDiff';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { GitDiffView } from '@/components/GitDiffView';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +24,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type RailTab = 'files' | 'git';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
@@ -45,12 +50,38 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
||||
});
|
||||
const [tab, setTab] = useState<RailTab>('files');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||
|
||||
// Git metadata: dirty dot on the Git tab (no new fetch — reuses the 30s poll).
|
||||
const git = useProjectGit(projectId);
|
||||
const isDirty = git?.is_dirty ?? false;
|
||||
|
||||
// Git diff view state (Phase 2: includes write callbacks).
|
||||
const { result: gitDiff, loading: gitLoading, error: gitError, mode: gitMode, selectMode, refresh: refreshDiff, mutating: gitMutating, mutateError: gitMutateError, stage: gitStage, unstage: gitUnstage, commit: gitCommit, discard: gitDiscard, modeSuggestion: gitModeSuggestion } = useGitDiff(projectId);
|
||||
const showGitTab = gitDiff === null || gitDiff.git_repo;
|
||||
|
||||
// FIX 5: pending-changes count — fetched when git tab is active so the empty state
|
||||
// can hint that unapplied pending changes exist in the Coder pane.
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (tab !== 'git') return;
|
||||
const check = () => {
|
||||
fetch(`/api/coder/sessions/${sessionId}/pending`)
|
||||
.then((r) => r.ok ? r.json() as Promise<Array<{ status: string }>> : [])
|
||||
.then((data) => setPendingCount(data.filter((c) => c.status === 'pending').length))
|
||||
.catch(() => {});
|
||||
};
|
||||
check();
|
||||
return sessionEvents.subscribe((e) => {
|
||||
if (e.type === 'git_diff_refresh') check();
|
||||
});
|
||||
}, [tab, sessionId]);
|
||||
|
||||
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
||||
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
||||
const [newFileOpen, setNewFileOpen] = useState(false);
|
||||
@@ -167,6 +198,11 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
return [];
|
||||
}, [filterActive, trimmed, fullFileList]);
|
||||
|
||||
// Trigger a git diff refresh whenever the Git tab becomes active.
|
||||
useEffect(() => {
|
||||
if (tab === 'git') sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}, [tab]);
|
||||
|
||||
// Listen for open_file_in_browser events
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
@@ -206,17 +242,45 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
return (
|
||||
<>
|
||||
<aside className={asideCls}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-medium flex-1">Files</span>
|
||||
{/* Header: Files / Git tab strip, FilePlus (Files only), close */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
onClick={() => setTab('files')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
tab === 'files' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
Files
|
||||
</button>
|
||||
{showGitTab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('git')}
|
||||
className={cn(
|
||||
'relative text-xs px-2 py-0.5 rounded max-md:min-h-[44px] flex items-center gap-1',
|
||||
tab === 'git' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Git
|
||||
{isDirty && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 shrink-0" aria-label="dirty" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{tab === 'files' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeRail}
|
||||
@@ -226,47 +290,73 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 shrink-0">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="h-7 text-xs"
|
||||
|
||||
{/* Files tab content */}
|
||||
{tab === 'files' && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 shrink-0">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1">
|
||||
{filterActive ? (
|
||||
filterResults.length > 0 ? (
|
||||
<ul className="list-none space-y-0.5">
|
||||
{filterResults.map((r) => (
|
||||
<li key={r.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
|
||||
onClick={() => void openFile(r.path)}
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="font-bold truncate">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||
)
|
||||
) : (
|
||||
<TreeLevel
|
||||
parentPath=""
|
||||
entries={rootEntries}
|
||||
cache={cache}
|
||||
expanded={expandedDirs}
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Git tab content */}
|
||||
{tab === 'git' && (
|
||||
<GitDiffView
|
||||
result={gitDiff}
|
||||
loading={gitLoading}
|
||||
error={gitError}
|
||||
mode={gitMode}
|
||||
onSelectMode={selectMode}
|
||||
onRefresh={refreshDiff}
|
||||
mutating={gitMutating}
|
||||
mutateError={gitMutateError}
|
||||
onStage={gitStage}
|
||||
onUnstage={gitUnstage}
|
||||
onCommit={gitCommit}
|
||||
onDiscard={gitDiscard}
|
||||
modeSuggestion={gitModeSuggestion}
|
||||
pendingCount={pendingCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1">
|
||||
{filterActive ? (
|
||||
filterResults.length > 0 ? (
|
||||
<ul className="list-none space-y-0.5">
|
||||
{filterResults.map((r) => (
|
||||
<li key={r.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
|
||||
onClick={() => void openFile(r.path)}
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="font-bold truncate">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||
)
|
||||
) : (
|
||||
<TreeLevel
|
||||
parentPath=""
|
||||
entries={rootEntries}
|
||||
cache={cache}
|
||||
expanded={expandedDirs}
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{viewerFile && (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -437,6 +438,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -446,6 +448,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
@@ -178,6 +178,12 @@ export interface RefetchMessagesEvent {
|
||||
type: 'refetch_messages';
|
||||
}
|
||||
|
||||
// git-diff-panel Phase 1: emitted client-side to trigger a panel refresh.
|
||||
// Not a WS frame — no @boocode/contracts change required.
|
||||
export interface GitDiffRefreshEvent {
|
||||
type: 'git_diff_refresh';
|
||||
}
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionRenamedEvent
|
||||
| ProjectCreatedEvent
|
||||
@@ -204,7 +210,8 @@ export type SessionEvent =
|
||||
| ProjectUnarchivedEvent
|
||||
| ProjectUpdatedEvent
|
||||
| ChatStatusEvent
|
||||
| RefetchMessagesEvent;
|
||||
| RefetchMessagesEvent
|
||||
| GitDiffRefreshEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
114
apps/web/src/hooks/useGitDiff.ts
Normal file
114
apps/web/src/hooks/useGitDiff.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export function useGitDiff(projectId: string | null | undefined) {
|
||||
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [result, setResult] = useState<GitDiffResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// FIX 4: non-null when user has pinned a mode that differs from the server's auto-selected mode.
|
||||
const [modeSuggestion, setModeSuggestion] = useState<GitDiffMode | null>(null);
|
||||
|
||||
// Coalescence guard: absorb concurrent refresh triggers into the running request.
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!projectId || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
|
||||
// dirty state (dirty → uncommitted, clean → committed).
|
||||
api.projects
|
||||
.gitDiff(projectId, pinned ? mode : null)
|
||||
.then((r) => {
|
||||
if (!pinned) {
|
||||
setMode(r.mode);
|
||||
}
|
||||
// FIX 4: if pinned and the server's auto-selected mode differs, surface a suggestion.
|
||||
if (pinned && r.auto_mode && r.auto_mode !== mode) {
|
||||
setModeSuggestion(r.auto_mode);
|
||||
} else {
|
||||
setModeSuggestion(null);
|
||||
}
|
||||
setResult(r);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff');
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId, mode, pinned]);
|
||||
|
||||
// Re-run refresh when mode changes (user pinned a new mode).
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'git_diff_refresh') refresh();
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
const selectMode = useCallback((m: GitDiffMode) => {
|
||||
setPinned(true);
|
||||
setMode(m);
|
||||
setModeSuggestion(null); // FIX 4: clear suggestion on explicit mode pick
|
||||
}, []);
|
||||
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [mutateError, setMutateError] = useState<string | null>(null);
|
||||
|
||||
const runMutation = useCallback(
|
||||
async (fn: () => Promise<unknown>): Promise<boolean> => {
|
||||
if (!projectId) return false;
|
||||
setMutating(true);
|
||||
setMutateError(null);
|
||||
try {
|
||||
await fn();
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
setMutateError(err instanceof Error ? err.message : 'Operation failed');
|
||||
return false;
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const stage = useCallback(
|
||||
(files: string[]) => runMutation(() => api.projects.gitStage(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const unstage = useCallback(
|
||||
(files: string[]) => runMutation(() => api.projects.gitUnstage(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(message: string, files?: string[]) =>
|
||||
runMutation(() => api.projects.gitCommit(projectId!, { message, files })),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const discard = useCallback(
|
||||
(files: GitDiscardFileInfo[]) => runMutation(() => api.projects.gitDiscard(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
return { result, loading, error, mode, selectMode, refresh, mutating, mutateError, stage, unstage, commit, discard, modeSuggestion };
|
||||
}
|
||||
@@ -273,6 +273,10 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
return;
|
||||
}
|
||||
setState((s) => applyFrame(s, frame));
|
||||
// Trigger git diff refresh after each completed assistant turn.
|
||||
if (frame.type === 'message_complete') {
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame', err);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'chat_deleted':
|
||||
case 'chat_status':
|
||||
case 'refetch_messages':
|
||||
case 'git_diff_refresh':
|
||||
// Consumed by useGitDiff; no sidebar state change needed.
|
||||
return prev;
|
||||
case 'project_archived': {
|
||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||
|
||||
@@ -396,10 +396,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRightRail}
|
||||
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
className="relative inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
aria-label="Toggle file browser"
|
||||
>
|
||||
<FolderTree className="size-5" />
|
||||
{git?.is_dirty && (
|
||||
<span className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-yellow-400" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user