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:
Yaojia Wang
2026-07-12 20:43:32 +02:00
parent 3076843e9c
commit b119c31019
9 changed files with 523 additions and 25 deletions

View File

@@ -15,8 +15,13 @@
* - diff content is carried verbatim in DiffLine.text — the FE renders it as
* inert text (AC-B1.4), never HTML.
*
* FR-B1.9 (`?base=<rev>`) is intentionally deferred to P2 (review #13): it needs
* a `git rev-parse --verify` allow-list before any revision reaches the CLI.
* FR-B1.9 (`?base=<rev>`) — diff a whole branch against a base commit-ish. The
* mitigation is a two-stage revision allow-list applied BEFORE any revision
* reaches the diff CLI: (1) `isPlausibleRev` — a pure syntactic boundary check
* (rejects flag-injection / `..` ranges / metachars); (2) `git rev-parse --verify`
* — git itself is the authoritative allow-list, and only its canonical sha output
* is passed to `git diff <sha>... --`, fully decoupling the raw user string from
* the diff invocation.
*/
import { execFile } from 'node:child_process'
@@ -33,6 +38,20 @@ import type {
const execFileAsync = promisify(execFile)
// ── base-revision allow-list (pure) ─────────────────────────────────────────
/** First-stage syntactic gate for a user-supplied `?base=<rev>` (FR-B1.9). A
* plausible commit-ish starts with an alphanumeric and uses only the safe git
* ref charset; this rejects flag injection (leading `-`), `..` ranges,
* whitespace and shell metacharacters BEFORE any git call. It is NOT a full
* ref validator — `git rev-parse --verify` (resolveBaseRev) is the
* authoritative allow-list; this only fails obvious junk fast. Never throws. */
export function isPlausibleRev(base: string): boolean {
if (typeof base !== 'string') return false
if (base.includes('..')) return false // block A..B / A...B ranges
return /^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/.test(base)
}
// ── numstat (pure) ──────────────────────────────────────────────────────────
/** One `git diff --numstat` row: `<added>\t<removed>\t<path>`; binary = `-\t-`. */
@@ -259,6 +278,9 @@ export function parseUnifiedDiff(patch: string, numstat?: Map<string, NumstatEnt
/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */
export interface GetDiffOptions {
staged: boolean
/** When set, diff the current HEAD against this base commit-ish (three-dot).
* Wins over `staged`; untracked files are not listed. Guarded by rev-parse. */
base?: string
cfg: Pick<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
}
@@ -339,16 +361,85 @@ async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number):
return files
}
/** Cap the file list at diffMaxFiles, propagating a truncation flag (DoS bound). */
function boundFiles(
files: readonly DiffFile[],
diffMaxFiles: number,
truncated: boolean,
): { files: DiffFile[]; truncated: boolean } {
if (files.length > diffMaxFiles) return { files: files.slice(0, diffMaxFiles), truncated: true }
return { files: [...files], truncated }
}
/**
* Read a repo's diff (working tree or `--staged`) as structured DiffResult.
* `repoPath` must already be a validated absolute git directory (route layer,
* SEC-H7). Best-effort: git failures yield an empty result rather than throwing.
* Resolve a user-supplied base revision to a canonical sha, or null (FR-B1.9).
* Two-stage allow-list: `isPlausibleRev` (defense-in-depth — the route also
* guards) then `git rev-parse --verify --quiet --end-of-options <base>^{commit}`.
* Only a `[0-9a-f]{7,64}` sha is accepted; anything else (unknown ref, non-commit
* peel, junk) → null. Never throws. The raw `base` is never interpolated: it is
* a single argv element after `--end-of-options`, and only the sha reaches diff.
*/
async function resolveBaseRev(
cwd: string,
base: string,
timeoutMs: number,
maxBytes: number,
): Promise<string | null> {
if (!isPlausibleRev(base)) return null
const { out } = await runGit(
cwd,
['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`],
timeoutMs,
maxBytes,
)
const sha = out.trim()
return /^[0-9a-f]{7,64}$/.test(sha) ? sha : null
}
/**
* Diff the current HEAD against a base commit-ish, three-dot (`<base>...HEAD` —
* the changes introduced on this branch since it diverged from base, matching a
* PR view). `base` is echoed on the result; untracked files are NOT listed.
* Best-effort: an unresolvable base → empty result rather than throwing.
*/
async function getBaseDiff(
repoPath: string,
base: string,
timeout: number,
maxBytes: number,
diffMaxFiles: number,
): Promise<DiffResult> {
const resolved = await resolveBaseRev(repoPath, base, timeout, maxBytes)
if (resolved === null) return { files: [], staged: false, truncated: false, base }
const range = `${resolved}...` // <sha>...HEAD; trailing `--` terminates options
const patch = await runGit(repoPath, ['diff', '--no-color', range, '--'], timeout, maxBytes)
const num = await runGit(repoPath, ['diff', '--numstat', range, '--'], timeout, maxBytes)
const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
const { files: bounded, truncated } = boundFiles(
files,
diffMaxFiles,
patch.truncated || num.truncated,
)
return { files: bounded, staged: false, truncated, base }
}
/**
* Read a repo's diff (working tree, `--staged`, or against a `base` revision) as
* a structured DiffResult. `repoPath` must already be a validated absolute git
* directory (route layer, SEC-H7). `opts.base` (when set) wins over `staged`.
* Best-effort: git failures yield an empty result rather than throwing.
*/
export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<DiffResult> {
const { staged, cfg } = opts
const { staged, base, cfg } = opts
const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg
const stagedArg = staged ? ['--staged'] : []
if (base !== undefined) {
return getBaseDiff(repoPath, base, timeout, maxBytes, diffMaxFiles)
}
const stagedArg = staged ? ['--staged'] : []
const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes)
const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes)
@@ -357,9 +448,10 @@ export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<D
files.push(...(await listUntracked(repoPath, timeout, maxBytes)))
}
let truncated = patch.truncated || num.truncated
const bounded =
files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files
const { files: bounded, truncated } = boundFiles(
files,
diffMaxFiles,
patch.truncated || num.truncated,
)
return { files: bounded, staged, truncated }
}

View File

@@ -39,7 +39,7 @@ import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
import { getDiff } from './http/diff.js'
import { getDiff, isPlausibleRev } from './http/diff.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
@@ -801,9 +801,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
// FR-B1.9: optional ?base=<rev> — diff HEAD against a base commit-ish. The
// rev-parse allow-list (in getDiff) is the real defense; isPlausibleRev
// rejects flag-injection/junk fast with a 400 before any git call.
const rawBase = req.query['base']
const base = typeof rawBase === 'string' && rawBase !== '' ? rawBase : undefined
if (base !== undefined && !isPlausibleRev(base)) {
res.status(400).json({ error: 'invalid base revision' })
return
}
try {
const staged = req.query['staged'] === '1'
res.json(await getDiff(target, { staged, cfg }))
res.json(await getDiff(target, { staged, base, cfg }))
} catch (err) {
console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read diff' })

View File

@@ -532,6 +532,7 @@ export interface DiffResult {
files: DiffFile[];
staged: boolean;
truncated: boolean;
base?: string; // echoed when the diff was against a base revision (?base=<rev>)
}
/* ── B3 worktree creation (§3.5) ── */