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.
114
src/http/diff.ts
114
src/http/diff.ts
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) ── */
|
||||
|
||||
@@ -129,6 +129,24 @@ describe('normalizeDiffResult', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: true })
|
||||
expect(result?.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes through an optional base revision (FR-B1.9)', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 'main' })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.base).toBe('main')
|
||||
})
|
||||
|
||||
it('leaves base undefined when the payload omits it', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.base).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores a non-string base (coerces to undefined)', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 123 })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.base).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── fetchDiff ─────────────────────────────────────────────────────────────────
|
||||
@@ -141,7 +159,7 @@ describe('fetchDiff', () => {
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const result = await fetchDiff('/some/repo', false)
|
||||
const result = await fetchDiff('/some/repo', { staged: false })
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('/projects/diff')
|
||||
@@ -157,20 +175,52 @@ describe('fetchDiff', () => {
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
await fetchDiff('/repo', true)
|
||||
await fetchDiff('/repo', { staged: true })
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('staged=true')
|
||||
})
|
||||
|
||||
it('defaults to staged=false when called with no options', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
await fetchDiff('/repo')
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('staged=false')
|
||||
})
|
||||
|
||||
it('builds a &base=<rev> URL (omitting staged) when base is set (FR-B1.9)', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ base: 'main' }),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const result = await fetchDiff('/repo', { base: 'main' })
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('base=main')
|
||||
expect(url).not.toContain('staged=')
|
||||
expect(result?.base).toBe('main')
|
||||
})
|
||||
|
||||
it('URL-encodes a base containing slashes (feature/x)', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
await fetchDiff('/repo', { base: 'feature/x' })
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain(`base=${encodeURIComponent('feature/x')}`)
|
||||
})
|
||||
|
||||
it('returns null on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
const result = await fetchDiff('/repo', { staged: false })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on fetch error (network failure)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
const result = await fetchDiff('/repo', { staged: false })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -179,7 +229,7 @@ describe('fetchDiff', () => {
|
||||
ok: true,
|
||||
json: async () => ({ not: 'a diff result' }),
|
||||
})))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
const result = await fetchDiff('/repo', { staged: false })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -480,4 +530,88 @@ describe('mountDiffViewer', () => {
|
||||
await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined()
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
// ── FR-B1.9: base-branch picker ─────────────────────────────────────────────
|
||||
|
||||
it('renders NO base picker when bases is absent (backward-compatible)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(container.querySelector('.df-base-select')).toBeNull()
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('renders NO base picker when bases is empty', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { bases: [] })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(container.querySelector('.df-base-select')).toBeNull()
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('renders a <select> with "Working tree" + one option per base', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const select = container.querySelector('.df-base-select') as HTMLSelectElement | null
|
||||
expect(select).not.toBeNull()
|
||||
const labels = Array.from(select!.options).map((o) => o.textContent)
|
||||
expect(labels).toEqual(['Working tree', 'main', 'dev'])
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('selecting a base fetches with base=<rev> and disables the Working/Staged tabs', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ base: 'main' }) }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
||||
select.value = 'main'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
|
||||
expect(lastUrl).toContain('base=main')
|
||||
expect(lastUrl).not.toContain('staged=')
|
||||
|
||||
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
|
||||
const stagedBtn = container.querySelectorAll('.df-tab')[1] as HTMLButtonElement
|
||||
expect(workingBtn.disabled).toBe(true)
|
||||
expect(stagedBtn.disabled).toBe(true)
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('selecting "Working tree" restores a staged-mode fetch and re-enables tabs', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
||||
// Enter base mode …
|
||||
select.value = 'main'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
// … then back to Working tree.
|
||||
select.value = ''
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
|
||||
expect(lastUrl).toContain('staged=false')
|
||||
expect(lastUrl).not.toContain('base=')
|
||||
|
||||
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
|
||||
expect(workingBtn.disabled).toBe(false)
|
||||
handle.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,6 +87,28 @@ async function makeRealRepo(): Promise<string> {
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Temp git repo on `main` with a `feature` branch that adds one committed file
|
||||
* containing a would-be XSS payload. Returns its absolute path. */
|
||||
async function makeTwoBranchRepo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-base-'))
|
||||
tmpDirs.push(dir)
|
||||
const git = (args: string[]): void => {
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
|
||||
}
|
||||
git(['init', '-b', 'main'])
|
||||
git(['config', 'user.email', 'test@localhost'])
|
||||
git(['config', 'user.name', 'Test'])
|
||||
git(['config', 'commit.gpgsign', 'false'])
|
||||
await fs.writeFile(path.join(dir, 'base.txt'), 'base\n', 'utf8')
|
||||
git(['add', '.'])
|
||||
git(['commit', '-m', 'base on main'])
|
||||
git(['checkout', '-b', 'feature'])
|
||||
await fs.writeFile(path.join(dir, 'feat.txt'), '<script>x</script>\n', 'utf8')
|
||||
git(['add', '.'])
|
||||
git(['commit', '-m', 'feature work'])
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (handles.length > 0) await handles.pop()?.close()
|
||||
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
|
||||
@@ -200,4 +222,54 @@ describe('GET /projects/diff (B1)', () => {
|
||||
// The diff carries the raw text verbatim (the FE renders it inert via textContent).
|
||||
expect(flat).toContain('<script>x</script>')
|
||||
})
|
||||
|
||||
// ── FR-B1.9: ?base=<rev> whole-branch diff ─────────────────────────────────
|
||||
|
||||
itGit('diffs the whole branch against a base (?base=main), echoing base', async () => {
|
||||
const repo = await makeTwoBranchRepo() // HEAD is on `feature`
|
||||
const { port } = await spawnServer()
|
||||
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=main`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const diff = (await res.json()) as DiffResult
|
||||
expect(diff.base).toBe('main')
|
||||
expect(diff.staged).toBe(false)
|
||||
const feat = diff.files.find((f) => f.newPath === 'feat.txt')
|
||||
expect(feat).toBeDefined()
|
||||
expect(feat?.status).toBe('added')
|
||||
expect(JSON.stringify(diff)).toContain('<script>x</script>')
|
||||
})
|
||||
|
||||
itGit('rejects a flag-injection base with 400 (?base=-rf)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=-rf`,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('rejects a metacharacter base with 400 (?base=main;rm)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}` +
|
||||
`&base=${encodeURIComponent('main; rm -rf /')}`,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('returns 200 with an empty diff for an unknown base (?base=ghost-branch)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=ghost-branch`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const diff = (await res.json()) as DiffResult
|
||||
expect(diff.base).toBe('ghost-branch')
|
||||
expect(diff.files).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -411,6 +411,29 @@ describe('renderProjectDetail — B1 View Diff', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the repo worktree branches + current branch as diff bases (FR-B1.9)', () => {
|
||||
const detail = makeDetail({
|
||||
path: '/home/user/proj',
|
||||
isGit: true,
|
||||
branch: 'main',
|
||||
worktrees: [
|
||||
{ path: '/home/user/proj', branch: 'main', isMain: true, isCurrent: true },
|
||||
{ path: '/home/user/proj-wt/feat', branch: 'feat', isMain: false, isCurrent: false },
|
||||
],
|
||||
})
|
||||
const root = renderProjectDetail(detail, makeHooks(), makeCbs())
|
||||
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
|
||||
|
||||
expect(mockMountDiffViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
'/home/user/proj',
|
||||
expect.objectContaining({ bases: expect.arrayContaining(['main', 'feat']) }),
|
||||
)
|
||||
// Deduped: `main` appears in both the current branch and a worktree → once.
|
||||
const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { bases: string[] }
|
||||
expect(opts.bases.filter((b) => b === 'main')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clicking "View Diff" shows the diff panel', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
|
||||
|
||||
Reference in New Issue
Block a user