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

@@ -47,7 +47,12 @@ export function normalizeDiffResult(raw: unknown): DiffResult | null {
.map(normalizeFile)
.filter((f): f is DiffFile => f !== null)
return { files, staged: o['staged'], truncated: o['truncated'] }
return {
files,
staged: o['staged'],
truncated: o['truncated'],
base: typeof o['base'] === 'string' ? o['base'] : undefined,
}
}
function normalizeFile(raw: unknown): DiffFile | null {
@@ -103,13 +108,26 @@ function normalizeLine(raw: unknown): DiffLine | null {
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
/** Options for {@link fetchDiff}: a `base` revision (branch/tag/sha) takes
* precedence over `staged` — the server ignores `staged` when `base` is set. */
export interface FetchDiffOpts {
staged?: boolean
base?: string
}
/**
* Fetch a diff from the server for the given repo path.
* Returns null on any error or invalid response (best-effort).
* Fetch a diff from the server for the given repo path. When `opts.base` is set
* the URL carries `&base=<rev>` (working-tree/staged are omitted); otherwise it
* carries `&staged=<bool>`. Returns null on any error or invalid response.
*/
export async function fetchDiff(repoPath: string, staged: boolean): Promise<DiffResult | null> {
export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Promise<DiffResult | null> {
try {
const url = `/projects/diff?path=${encodeURIComponent(repoPath)}&staged=${staged}`
let url = `/projects/diff?path=${encodeURIComponent(repoPath)}`
if (opts.base !== undefined && opts.base !== '') {
url += `&base=${encodeURIComponent(opts.base)}`
} else {
url += `&staged=${opts.staged === true}`
}
const res = await fetch(url)
if (!res.ok) return null
const data: unknown = await res.json()
@@ -240,6 +258,9 @@ export interface DiffViewerHandle {
export interface MountDiffViewerOpts {
/** Called when the viewer's close button or close() is invoked. */
onClose?: () => void
/** Base revisions (branches) offered in the "compare base" picker. When
* empty/omitted no picker is rendered (backward-compatible). */
bases?: string[]
}
/**
@@ -257,6 +278,7 @@ export function mountDiffViewer(
): DiffViewerHandle {
let destroyed = false
let staged = false
let base: string | null = null // null → working-tree/staged mode; string → base-diff
// ── skeleton ──────────────────────────────────────────────────────────────
const root = el('div', 'df-viewer')
@@ -268,9 +290,44 @@ export function mountDiffViewer(
const stagedBtn = el('button', 'df-tab', 'Staged')
const closeBtn = el('button', 'df-close', '✕ Close')
toolbar.append(workingBtn, stagedBtn, closeBtn)
toolbar.append(workingBtn, stagedBtn)
// Optional "compare against base" picker: Working tree + one option per base.
const bases = opts.bases ?? []
let baseSelect: HTMLSelectElement | null = null
if (bases.length > 0) {
baseSelect = document.createElement('select')
baseSelect.className = 'df-base-select'
baseSelect.setAttribute('aria-label', 'Compare against base')
const wtOpt = el('option', undefined, 'Working tree')
wtOpt.value = ''
baseSelect.append(wtOpt)
for (const b of bases) {
const opt = el('option', undefined, b) // textContent — inert (SEC-H4)
opt.value = b
baseSelect.append(opt)
}
baseSelect.addEventListener('change', () => {
const v = baseSelect?.value ?? ''
base = v === '' ? null : v
updateTabState()
void loadDiff()
})
toolbar.append(baseSelect)
}
toolbar.append(closeBtn)
root.append(toolbar)
// In base mode the Working/Staged tabs are inert (a base diff can't be staged).
function updateTabState(): void {
const inBase = base !== null
workingBtn.disabled = inBase
stagedBtn.disabled = inBase
workingBtn.classList.toggle('df-tab-disabled', inBase)
stagedBtn.classList.toggle('df-tab-disabled', inBase)
}
// Content area
const content = el('div', 'df-content')
root.append(content)
@@ -304,7 +361,7 @@ export function mountDiffViewer(
content.textContent = 'Loading…'
const result = await fetchDiff(repoPath, staged)
const result = await fetchDiff(repoPath, base !== null ? { base } : { staged })
if (destroyed) return