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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user