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:
2026-06-03 03:18:41 +00:00
parent f32fd928b3
commit d8bb2dabfe
14 changed files with 2290 additions and 49 deletions

View 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'));
}
});
});

View 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();
});
});