feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.
- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
attacker-supplied base never reaches a shell or acts as a git option:
(1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
<base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
(3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
disables Working/Staged tabs in base mode. public/projects.ts derives bases from
the worktree branches ∪ current branch.
Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
parseNumstat,
|
||||
parseUnifiedDiff,
|
||||
getDiff,
|
||||
isPlausibleRev,
|
||||
type NumstatEntry,
|
||||
type GetDiffOptions,
|
||||
} from '../../src/http/diff.js'
|
||||
@@ -30,6 +31,44 @@ const LIMITS: GetDiffOptions['cfg'] = {
|
||||
diffMaxFiles: 300,
|
||||
}
|
||||
|
||||
// ── isPlausibleRev (FR-B1.9 syntactic allow-list) ────────────────────────────
|
||||
|
||||
describe('isPlausibleRev', () => {
|
||||
it('accepts ordinary commit-ish forms', () => {
|
||||
for (const ok of ['main', 'feature/x', 'HEAD~3', 'v1.2.0', 'main^', 'HEAD@{1}',
|
||||
'0123456789abcdef0123456789abcdef01234567']) {
|
||||
expect(isPlausibleRev(ok)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the empty string', () => {
|
||||
expect(isPlausibleRev('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an over-long revision (>250 chars)', () => {
|
||||
expect(isPlausibleRev('a'.repeat(300))).toBe(false)
|
||||
expect(isPlausibleRev('a'.repeat(250))).toBe(true) // boundary
|
||||
})
|
||||
|
||||
it('rejects flag/option injection (leading hyphen)', () => {
|
||||
expect(isPlausibleRev('-rf')).toBe(false)
|
||||
expect(isPlausibleRev('--output=/etc/passwd')).toBe(false)
|
||||
expect(isPlausibleRev('--upload-pack=touch /tmp/pwn')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects range syntax (.. and ...)', () => {
|
||||
expect(isPlausibleRev('a..b')).toBe(false)
|
||||
expect(isPlausibleRev('a...b')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects whitespace and shell metacharacters', () => {
|
||||
for (const bad of ['x y', 'a\tb', '`id`', '$(x)', 'a;b', 'a|b', 'a&b', 'a>b', "a'b", 'a"b',
|
||||
'main; rm -rf /', 'a\x00b']) {
|
||||
expect(isPlausibleRev(bad)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── parseNumstat ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseNumstat', () => {
|
||||
@@ -346,3 +385,74 @@ describe('getDiff (real git repo)', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user