# Diff against a base branch (?base=) Adds an optional `base` revision to the read-only git-diff side-channel so the viewer can compare a whole branch against `main` (or any commit-ish), not just the working tree / index. This lands the `FR-B1.9` deferral called out in `src/http/diff.ts:18-19`, using the exact mitigation named there: a `git rev-parse --verify` allow-list before any revision reaches the diff CLI. The diff **parsers and the render core stay untouched**; only a two-stage revision guard (backend), a reflected `base` field, and a toolbar picker (frontend) are added. --- ## Contract ### Route (unchanged path, one new optional query param) `GET /projects/diff` (`src/server.ts:661-678`) | Param | Type | Notes | |---|---|---| | `path` | string (required) | absolute git dir; validated by `isValidGitDir` (`src/server.ts:122-133`) — unchanged | | `staged` | `0`\|`1` (optional) | current behavior; **ignored when `base` is present** | | `base` | string (optional) | a commit-ish (branch/tag/sha/`HEAD~N`). When present → three-dot diff `git diff ... --`; untracked files are not listed | Response is the existing `DiffResult` JSON, now with an optional reflected `base`: - `200` structured `DiffResult` (with `base` echoed when supplied) - `400 {error}` — missing `path`, **or** a `base` that fails the syntactic pre-check (flag injection / junk) - `404 {error}` — path not a git dir (unchanged) - Best-effort: a syntactically-valid but **unknown/unrelated** `base` (rev-parse miss, no merge-base) yields `200` with `files: []` — consistent with the module's "git failure → empty, never throw" house style (`src/http/diff.ts:14`, `:346`). ### Message / data types — `src/types.ts` (coordination edit) Extend `DiffResult` (`src/types.ts:475-479`) with one **optional** field so the shape stays backward-compatible and the viewer's required-field validation is unaffected: ``` export interface DiffResult { files: DiffFile[]; staged: boolean; truncated: boolean; base?: string; // NEW — echoed when the diff was against a base revision } ``` No other shared type changes. `GetDiffOptions` lives in `src/http/diff.ts:260-263` (not `types.ts`) and gains `base?: string`. ### Env vars — `src/config.ts` **None required.** rev-parse + diff reuse the existing `diffTimeoutMs` / `diffMaxBytes` bounds (`src/config.ts:347-362`). *(Optional kill-switch `DIFF_BASE_ENABLED` (default true) could be added mirroring `worktreeEnabled` at `src/config.ts:372` if a runtime disable is wanted — deferred, not needed for correctness.)* ### New/changed function signatures — `src/http/diff.ts` ``` export function isPlausibleRev(base: string): boolean // pure boundary check async function resolveBaseRev(cwd, base, timeoutMs, maxBytes): Promise // rev-parse --verify → canonical sha | null export interface GetDiffOptions { staged: boolean; base?: string; cfg: Pick } // +base export async function getDiff(repoPath, opts): Promise // branches on opts.base ``` --- ## Files to change | Path | Concrete change | |---|---| | `src/types.ts` | **Coordination edit.** Add optional `base?: string` to `DiffResult` (`:475-479`). | | `src/http/diff.ts` | Add exported pure `isPlausibleRev` (charset + no-`..` + no-leading-`-` + length≤250). Add `resolveBaseRev` (runs `git rev-parse --verify --quiet --end-of-options ^{commit}` via `runGit` `:289-309`; return trimmed `/^[0-9a-f]{7,64}$/` sha or `null`). Add `base?` to `GetDiffOptions` (`:260-263`). In `getDiff` (`:347-365`): if `opts.base` set → `resolved = resolveBaseRev(...)`; `null` → `{files:[],staged:false,truncated:false,base:opts.base}`; else run `git diff --no-color ... --` and `git diff --numstat ... --`, **skip** `listUntracked` (`:322-340`), set `staged:false`, echo `base:opts.base`. Working-tree path unchanged. | | `src/server.ts` | Diff route (`:661-678`): read `base` (`typeof q==='string' && q!=='' ? q : undefined`); if present and `!isPlausibleRev(base)` → `400 {error:'invalid base revision'}`; else pass `base` into `getDiff(target,{staged,base,cfg})`. Import `isPlausibleRev` from `./http/diff.js`. Route stays no-Origin-guard (read-only, unchanged threat model). | | `public/diff.ts` | `fetchDiff` (`:110-120`): change signature to `fetchDiff(repoPath, opts:{staged?:boolean; base?:string})`; build URL with `&base=` (omit `staged`) when `base` set, else `&staged=`. `normalizeDiffResult` (`:38-51`): pass through optional `base` (`typeof o['base']==='string' ? o['base'] : undefined`; keep other fields required). `MountDiffViewerOpts` (`:240-243`): add `bases?: string[]`. `mountDiffViewer` (`:253-347`): add a `` with options `Working tree` + `main` + `dev`. - Selecting `main` (dispatch `change`) triggers a fetch whose URL contains `base=main` and disables the Working/Staged tabs; selecting `Working tree` restores a `staged`-mode fetch. - Empty/absent `bases` → no `