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

@@ -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