/**
* 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` [31mred'
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)
}
})
})