/** * test/http/diff.test.ts (N-diff-be, B1) — read-only git diff parsing + getDiff. * * Two layers: * 1. Pure parsers (parseNumstat / parseUnifiedDiff) fed canned git output — the * deterministic core (AC-B1.5: rename/new/delete/binary/empty/untracked, +/- * consistent with `git diff --numstat`; never throws; `cmd` red' const patch = [ 'diff --git a/x.txt b/x.txt', '--- a/x.txt', '+++ b/x.txt', '@@ -0,0 +1 @@', '+' + payload, '', ].join('\n') const [file] = parseUnifiedDiff(patch) expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'added', text: payload }) }) it('parses several files in one patch', () => { const patch = MODIFIED + [ 'diff --git a/bar.txt b/bar.txt', '--- a/bar.txt', '+++ b/bar.txt', '@@ -1 +1 @@', '-one', '+two', '', ].join('\n') const files = parseUnifiedDiff(patch) expect(files.map((f) => f.newPath)).toEqual(['foo.txt', 'bar.txt']) }) }) // ── getDiff (integration against a real git repo) ──────────────────────────── async function git(cwd: string, ...args: string[]): Promise { await execFileAsync('git', args, { cwd }) } describe('getDiff (real git repo)', () => { let repo: string beforeAll(async () => { repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-diff-')) await git(repo, 'init', '-q', '-b', 'main') await git(repo, 'config', 'user.email', 'test@example.com') await git(repo, 'config', 'user.name', 'Test') await git(repo, 'config', 'commit.gpgsign', 'false') await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n') await git(repo, 'add', '.') await git(repo, 'commit', '-q', '-m', 'init') }) afterAll(async () => { await fs.rm(repo, { recursive: true, force: true }) }) it('returns structured working-tree diff with numstat-consistent counts', async () => { await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\nTWO\nthree\nfour\n') const result = await getDiff(repo, { staged: false, cfg: LIMITS }) expect(result.staged).toBe(false) expect(result.truncated).toBe(false) const file = result.files.find((f) => f.newPath === 'tracked.txt') expect(file).toBeDefined() expect(file?.status).toBe('modified') // changed line two + added line four = 2 added, 1 removed expect(file?.added).toBe(2) expect(file?.removed).toBe(1) // revert for later tests await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n') }) it('lists untracked files via porcelain (L3, working tree only)', async () => { await fs.writeFile(path.join(repo, 'fresh.txt'), 'brand new\n') const result = await getDiff(repo, { staged: false, cfg: LIMITS }) const untracked = result.files.find((f) => f.newPath === 'fresh.txt') expect(untracked?.status).toBe('untracked') await fs.rm(path.join(repo, 'fresh.txt')) }) it('does not include untracked files in the staged diff', async () => { await fs.writeFile(path.join(repo, 'staged-only.txt'), 'queued\n') await git(repo, 'add', 'staged-only.txt') const result = await getDiff(repo, { staged: true, cfg: LIMITS }) expect(result.staged).toBe(true) const file = result.files.find((f) => f.newPath === 'staged-only.txt') expect(file?.status).toBe('added') expect(result.files.every((f) => f.status !== 'untracked')).toBe(true) await git(repo, 'reset', '-q') await fs.rm(path.join(repo, 'staged-only.txt')) }) it('marks truncated when the file count exceeds diffMaxFiles', async () => { for (let i = 0; i < 5; i++) { await fs.writeFile(path.join(repo, `gen-${i}.txt`), `content ${i}\n`) } const result = await getDiff(repo, { staged: false, cfg: { ...LIMITS, diffMaxFiles: 2 }, }) expect(result.truncated).toBe(true) expect(result.files.length).toBeLessThanOrEqual(2) for (let i = 0; i < 5; i++) await fs.rm(path.join(repo, `gen-${i}.txt`)) }) it('never throws for a non-existent / non-git path (returns empty)', async () => { const result = await getDiff(path.join(os.tmpdir(), 'definitely-not-here-xyz'), { staged: false, cfg: LIMITS, }) expect(result.files).toEqual([]) expect(result.truncated).toBe(false) }) }) // ── getDiff against a base revision (FR-B1.9, real git repo) ────────────────── describe('getDiff against a base revision (FR-B1.9)', () => { let repo: string beforeAll(async () => { repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-basediff-')) await git(repo, 'init', '-q', '-b', 'main') await git(repo, 'config', 'user.email', 'test@example.com') await git(repo, 'config', 'user.name', 'Test') await git(repo, 'config', 'commit.gpgsign', 'false') await fs.writeFile(path.join(repo, 'app.txt'), 'base one\nbase two\n') await git(repo, 'add', '.') await git(repo, 'commit', '-q', '-m', 'base commit on main') // Branch off and add a feature-only change (committed on `feature`, not main). await git(repo, 'checkout', '-q', '-b', 'feature') await fs.writeFile(path.join(repo, 'feature.txt'), 'brand new feature file\n') await git(repo, 'add', '.') await git(repo, 'commit', '-q', '-m', 'feature work') }) afterAll(async () => { await fs.rm(repo, { recursive: true, force: true }) }) it('diffs the whole branch against main (three-dot), echoing base', async () => { const result = await getDiff(repo, { staged: false, base: 'main', cfg: LIMITS }) expect(result.base).toBe('main') expect(result.staged).toBe(false) expect(result.truncated).toBe(false) const feat = result.files.find((f) => f.newPath === 'feature.txt') expect(feat).toBeDefined() expect(feat?.status).toBe('added') // numstat-consistent counts for the committed feature file expect(feat?.added).toBe(1) expect(feat?.removed).toBe(0) // A base diff never lists untracked entries. expect(result.files.every((f) => f.status !== 'untracked')).toBe(true) }) it('does NOT include working-tree-only untracked files in a base diff', async () => { await fs.writeFile(path.join(repo, 'scratch.txt'), 'not committed\n') const result = await getDiff(repo, { staged: false, base: 'main', cfg: LIMITS }) expect(result.files.find((f) => f.newPath === 'scratch.txt')).toBeUndefined() await fs.rm(path.join(repo, 'scratch.txt')) }) it('returns an empty diff when HEAD equals the base (no branch changes)', async () => { const result = await getDiff(repo, { staged: false, base: 'feature', cfg: LIMITS }) expect(result.base).toBe('feature') expect(result.files).toEqual([]) expect(result.truncated).toBe(false) }) it('returns an empty result for an unknown/unresolvable base (rev-parse miss)', async () => { const result = await getDiff(repo, { staged: false, base: 'no-such-branch', cfg: LIMITS }) expect(result.base).toBe('no-such-branch') expect(result.files).toEqual([]) expect(result.truncated).toBe(false) }) it('never runs a diff for an implausible base (defense-in-depth, no throw)', async () => { // The route rejects this with 400 before getDiff; getDiff must still be safe. for (const bad of ['-x', '--output=/tmp/x', 'main; rm -rf /', 'a..b']) { const result = await getDiff(repo, { staged: false, base: bad, cfg: LIMITS }) expect(result.files).toEqual([]) expect(result.base).toBe(bad) } }) })