feat(git-status): click a file in the Git panel to see its diff

Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
Devvyn
2026-10-05 09:14:07 +08:00
co-authored by Claude Sonnet 5.5
parent bfc164a262
commit cd9218c23e
8 changed files with 402 additions and 13 deletions
+65
View File
@@ -660,3 +660,68 @@ describe('getGitWorkspaceOverview', () => {
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
});
});
import { MAX_DIFF_BYTES, getGitFileDiff, isSafeRepoRelativePath } from '../src/git-workspace-status.js';
describe('isSafeRepoRelativePath', () => {
it.each([
['a.txt', true],
['src/deep/x.ts', true],
['', false],
['-rf', false],
['/etc/passwd', false],
['../x', false],
['a/../../x', false],
['a\0b', false],
])('%j -> %s', (p, ok) => expect(isSafeRepoRelativePath(p)).toBe(ok));
});
describe('getGitFileDiff', () => {
it('refuses an unsafe path without running git', async () => {
const git = vi.fn(async () => '');
await expect(getGitFileDiff('/r', { path: '../x', kind: 'unstaged' }, { git })).rejects.toThrow('Invalid path');
expect(git).not.toHaveBeenCalled();
});
it('builds read-only, option-injection-safe commands per kind', async () => {
const git = vi.fn(async (_cwd: string, _args: string[]) => '');
await getGitFileDiff('/r', { path: 'a.txt', kind: 'unstaged' }, { git });
await getGitFileDiff('/r', { path: 'b.txt', origPath: 'old.txt', kind: 'staged' }, { git });
const [unstaged, staged] = git.mock.calls.map((c) => c[1]);
for (const args of [unstaged, staged]) {
expect(args).toContain('--no-ext-diff');
expect(args).toContain('--no-textconv');
expect(args.indexOf('--')).toBeGreaterThan(0);
}
expect(unstaged.slice(-2)).toEqual(['--', 'a.txt']);
expect(staged).toContain('--cached');
expect(staged.slice(-3)).toEqual(['--', 'old.txt', 'b.txt']);
});
it('treats --no-index exit 1 as the normal untracked result', async () => {
const git = vi.fn(async () => {
throw Object.assign(new Error('exit 1'), { code: 1, stdout: '+hello\n' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git })).resolves.toMatchObject({
diff: '+hello\n',
});
const boom = vi.fn(async () => {
throw Object.assign(new Error('exit 128'), { code: 128, stdout: '' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git: boom })).rejects.toThrow();
});
it('flags binary output and cuts an oversized diff at a line boundary', async () => {
const bin = await getGitFileDiff(
'/r',
{ path: 'x.png', kind: 'unstaged' },
{ git: async () => 'Binary files a/x.png and b/x.png differ\n' }
);
expect(bin.binary).toBe(true);
const big = ('+' + 'x'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) + 50);
const cut = await getGitFileDiff('/r', { path: 'big', kind: 'unstaged' }, { git: async () => big });
expect(cut.truncated).toBe(true);
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true);
});
});