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:
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user