mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
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:
co-authored by
Claude Sonnet 5.5
parent
bfc164a262
commit
cd9218c23e
@@ -168,21 +168,39 @@ describe('Git status indicator in a real browser', () => {
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clicking a file previews it by its absolute path; a deleted file is not clickable', async () => {
|
||||
it('clicking a file shows its diff in the panel; Back returns to the list; Open file previews it', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
|
||||
});
|
||||
await page.click('.git-status-file:has-text("a.txt")');
|
||||
await page.waitForSelector('#gitStatusBody .git-diff');
|
||||
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('a.txt');
|
||||
expect(await page.textContent('#gitStatusBody .git-diff-line--del')).toBe('-1\n');
|
||||
expect(await page.textContent('#gitStatusBody .git-diff-line--add')).toBe('+2\n');
|
||||
// The 15 s poll re-renders the panel; the diff must survive it.
|
||||
await refresh();
|
||||
expect(await page.$('#gitStatusBody .git-diff')).not.toBeNull();
|
||||
await page.click('#gitStatusBody button:has-text("Open file")');
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
|
||||
await page.click('#gitStatusBody button:has-text("Back")');
|
||||
await page.waitForSelector('.git-status-file:has-text("a.txt")');
|
||||
expect(await page.$('#gitStatusBody .git-diff')).toBeNull();
|
||||
});
|
||||
|
||||
it('an untracked file diffs as all additions, and a deleted file as all removals (with no Open file)', async () => {
|
||||
await page.click('.git-status-file:has-text("new file.txt")');
|
||||
await page.waitForSelector('#gitStatusBody .git-diff-line--add');
|
||||
expect(await page.locator('#gitStatusBody .git-diff-line--del').count()).toBe(0);
|
||||
await page.click('#gitStatusBody button:has-text("Back")');
|
||||
rmSync(join(repo, 'b.txt'));
|
||||
await refresh();
|
||||
await page.waitForFunction(() =>
|
||||
/Deleted|b\.txt/.test(document.getElementById('gitStatusBody')!.textContent ?? '')
|
||||
);
|
||||
const deleted = page.locator('.git-status-file:has(.git-status-badge--D)');
|
||||
expect(await deleted.count()).toBe(1);
|
||||
expect(await deleted.first().getAttribute('role')).toBeNull();
|
||||
await page.waitForSelector('.git-status-file:has(.git-status-badge--D)');
|
||||
await page.click('.git-status-file:has(.git-status-badge--D)');
|
||||
await page.waitForSelector('#gitStatusBody .git-diff-line--del');
|
||||
expect(await page.locator('#gitStatusBody .git-diff-line--add').count()).toBe(0);
|
||||
expect(await page.locator('#gitStatusBody button:has-text("Open file")').count()).toBe(0);
|
||||
await page.click('#gitStatusBody button:has-text("Back")');
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* all (remote and Docker sessions). Port: N/A (app.inject()).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -153,3 +153,66 @@ describe('GET /api/sessions/:id/git-status', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/git-diff', () => {
|
||||
const url = (q: Record<string, string>) => `/api/sessions/test-session-1/git-diff?${new URLSearchParams(q)}`;
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\n');
|
||||
writeFileSync(join(dir, 'b.txt'), 'bee\n');
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-q', '-m', 'base');
|
||||
writeFileSync(join(dir, 'a.txt'), 'two\n');
|
||||
writeFileSync(join(dir, 'b.txt'), 'staged\n');
|
||||
git(dir, 'add', 'b.txt');
|
||||
writeFileSync(join(dir, 'new.txt'), 'fresh\n');
|
||||
root = realpathSync(dir);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unstaged', 'a.txt', ['-one', '+two']],
|
||||
['staged', 'b.txt', ['-bee', '+staged']],
|
||||
['untracked', 'new.txt', ['+fresh']],
|
||||
])('returns the %s diff of %s', async (kind, path, lines) => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: url({ repo: root, path, kind }) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { diff, truncated, binary } = res.json().data;
|
||||
for (const l of lines) expect(diff).toContain(l);
|
||||
expect(truncated).toBe(false);
|
||||
expect(binary).toBe(false);
|
||||
});
|
||||
|
||||
it('answers 404 for a path or repo the status does not list, running no diff', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
for (const q of [
|
||||
{ repo: root, path: '../../etc/passwd', kind: 'unstaged' },
|
||||
{ repo: '/etc', path: 'a.txt', kind: 'unstaged' },
|
||||
{ repo: root, path: 'a.txt', kind: 'staged' },
|
||||
]) {
|
||||
const res = await app.inject({ method: 'GET', url: url(q) });
|
||||
expect(res.statusCode).toBe(404);
|
||||
}
|
||||
expect(runner.mock.calls.some(([, args]) => args[0] === 'diff')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not run git for remote and Docker sessions', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
session.remote = { host: 'h' };
|
||||
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await setup({ authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user