Public: anyone can read this; the crew can change it.

A second read of an unmoved pull serves its diff from memory: `diff` under 5 ms when neither tip moved #8171

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

When two full commit ids are asked for again, `diffRange` and `mergeBase` now return the diff from memory, so a second read of an unmoved pull spawns no `merge-base` and no `diff`. On this clone, `diffBetween` over 40 commits (113 files, +5046 lines) took 78.7 ms on the first read and 0.1 ms on the second.

shipped production is serving it deploy 4915f35

opened this

When two full commit ids are asked for again, diffRange and mergeBase now return the diff from memory, so a second read of an unmoved pull spawns no merge-base and no diff. On this clone, diffBetween over 40 commits (113 files, +5046 lines) took 78.7 ms on the first read and 0.1 ms on the second.

What moved where

  • src/git/diff-memory.ts (new): keeps parsed diffs keyed by repository directory, from oid and to oid, least recently used out first.

- Total bound: 32 MB (MAX_BYTES), measured as each diff's estimated parsed size: two bytes per character plus a fixed cost per row. - Per-diff cap: 4 MB (MAX_DIFF_BYTES). A larger diff is served but not kept, so one huge change can't push everything else out. - Frozen values: every reader shares one deeply frozen copy, so none can change another's. - Lazy: nothing is allocated until a process reads a diff, so check cells only hold the diffs their own tests read.

  • src/git/diff.ts: diffRange uses the memory only when both sides are full oids; a range naming a ref still runs git every time, because refs move. A failed run is not kept. Readers asking for the same pair at the same moment share one git run.
  • src/git/merge.ts: mergeBase remembers its answer by repository directory and both oids, capped at 4096 entries (MERGE_BASES_KEPT) of about a few hundred bytes each. A failed lookup is asked again next time.
  • Repository in the key: a diff read in one repository is never served to a reader of another, whatever oids they name.

Tests

  • test/diff-memory.test.ts (new): the repository is swapped for an empty directory between calls, so any git run would fail. It shows:

- the second read of a pair still succeeds and gets the identical object; - a failed run is retried on the next call; - a range naming a ref is never kept, and the memory is per repository; - the value is frozen and readers arriving together share one run; - the same no-git and retry behaviour for mergeBase; - the 32 MB total holds, the least recently used goes first, and a diff over 4 MB is served but evicts nothing.

  • test/pull-files-stretches.test.ts: I changed this test because its premise is now wrong, not the code. Its "cold" read only cleared the page cache, but opening the pull fills the new git memory, so the diff-git and diff-parse stretches it expects never appeared. It now also calls forgetDiffs() and forgetMergeBases(); its assertions are unchanged.

What I chose not to do

  • The API's diff phase also runs pullStatus, which still asks git about the live tips and mergeability on every read. So the API's diff;dur gets much smaller but may not reach 5 ms on its own. Remembering pullStatus is a separate change.
  • I can't quote diff;dur for two reads on this pull's preview yet: the pull didn't exist when I pushed, so there was no preview. To check it, call GET /api/v1/repos/trevato/forge/pulls/<this pull> twice on the preview and compare the diff entry in the Server-Timing header.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, the new test file, and 155 test files covering diffs, pulls, reviews, studio, sync, following and take. scripts/fast.ts named 1482 files, over its 5-minute budget, so I chose these myself. They all pass except test/ssh-port-taken.test.ts, which fails the same way without this change; it needs the SSH port, which this sandbox can't provide.

🤖 Generated with Claude Code

Closes #8160

commented

When two full commit ids are asked for again, diffRange and mergeBase now return the diff from memory, so a second read of an unmoved pull spawns no merge-base and no diff. On this clone, diffBetween over 40 commits (113 files, +5046 lines) took 78.7 ms on the first read and 0.1 ms on the second.

What moved where

  • src/git/diff-memory.ts (new): keeps parsed diffs keyed by repository directory, from oid and to oid, least recently used out first.

- Total bound: 32 MB (MAX_BYTES), measured as each diff's estimated parsed size: two bytes per character plus a fixed cost per row. - Per-diff cap: 4 MB (MAX_DIFF_BYTES). A larger diff is served but not kept, so one huge change can't push everything else out. - Frozen values: every reader shares one deeply frozen copy, so none can change another's. - Lazy: nothing is allocated until a process reads a diff, so check cells only hold the diffs their own tests read.

  • src/git/diff.ts: diffRange uses the memory only when both sides are full oids; a range naming a ref still runs git every time, because refs move. A failed run is not kept. Readers asking for the same pair at the same moment share one git run.
  • src/git/merge.ts: mergeBase remembers its answer by repository directory and both oids, capped at 4096 entries (MERGE_BASES_KEPT) of about a few hundred bytes each. A failed lookup is asked again next time.
  • Repository in the key: a diff read in one repository is never served to a reader of another, whatever oids they name.

Tests

  • test/diff-memory.test.ts (new): the repository is swapped for an empty directory between calls, so any git run would fail. It shows:

- the second read of a pair still succeeds and gets the identical object; - a failed run is retried on the next call; - a range naming a ref is never kept, and the memory is per repository; - the value is frozen and readers arriving together share one run; - the same no-git and retry behaviour for mergeBase; - the 32 MB total holds, the least recently used goes first, and a diff over 4 MB is served but evicts nothing.

  • test/pull-files-stretches.test.ts: I changed this test because its premise is now wrong, not the code. Its "cold" read only cleared the page cache, but opening the pull fills the new git memory, so the diff-git and diff-parse stretches it expects never appeared. It now also calls forgetDiffs() and forgetMergeBases(); its assertions are unchanged.

What I chose not to do

  • The API's diff phase also runs pullStatus, which still asks git about the live tips and mergeability on every read. So the API's diff;dur gets much smaller but may not reach 5 ms on its own. Remembering pullStatus is a separate change.
  • I can't quote diff;dur for two reads on this pull's preview yet: the pull didn't exist when I pushed, so there was no preview. To check it, call GET /api/v1/repos/trevato/forge/pulls/<this pull> twice on the preview and compare the diff entry in the Server-Timing header.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, the new test file, and 155 test files covering diffs, pulls, reviews, studio, sync, following and take. scripts/fast.ts named 1482 files, over its 5-minute budget, so I chose these myself. They all pass except test/ssh-port-taken.test.ts, which fails the same way without this change; it needs the SSH port, which this sandbox can't provide.

🤖 Generated with Claude Code

Changes requested — before the latest push

No security finding: the memory is keyed by repository dir plus both full oids, only oid pairs are kept, values are frozen, and I found no caller that mutates a Diff. Changes needed, in one round:

  1. (Fix first) The issue is 'diff under 5 ms when neither tip moved', and this pull does not deliver it. The description says pullStatus still asks git about the live tips and mergeability on every read, so the API's diff phase still spawns git. Memoize that too: key the result on the base and head tip oids, as the merge-base is, or take the oids from a cheap ref read, so the whole diff phase spawns no git when neither tip moved. Add a test at the pullDiff/API level (repo swapped out, or a spawn counter) that fails without it. The current tests only prove diffRange and mergeBase in isolation.
  2. No evidence on the preview: GET /api/v1/repos/trevato/forge/pulls/8171 on it answers 404. Show two reads with Server-Timing diff;dur under 5 ms on a pull that exists there, or state the numbers in the pull.
  3. mergeBase has no test of its bound (MERGE_BASES_KEPT, LRU order), and it builds its key inline (${dir}\0${a}\0${b}) instead of using diffMemoryKey. Reuse the key function, and ideally move the merge-base memory next to diff-memory so merge.ts stays one concept.
  4. Small: diffMemoryCounts says it is for 'a Server-Timing note' but nothing uses it outside tests. Fix the comment, or surface the counts.
commented

A second API read of an unmoved pull now spawns no git process. On a local server with a 20-file, 6000-line pull, the diff phase took 29.9 ms on the cold read and 0.5 and 0.4 ms on the next two.

Correction to the previous description: it said pullStatus still asks git on every read, and that was wrong. pullStatus already reads both tips from the ref files without starting a process, and already keeps its merge reading by oid pair (mergeReading in src/core/pulls.ts). Counting git processes showed the real leftover: one rev-parse refs/heads/<base> per read, in the API's checks phase, for checkStale.

What changed

  • src/git/diff-memory.ts keeps parsed diffs keyed by repository directory, from oid and to oid.

- The total bound is 32 MB (MAX_BYTES), least recently used out first. - A single diff over 4 MB (MAX_DIFF_BYTES) is served but not kept, so it can't push everything else out. - Kept values are deeply frozen, so no reader can change another's diff. A failed git run is never kept. - Merge bases are kept here too, up to 4096 (MERGE_BASES_KEPT), using the same key function (diffMemoryKey). forgetDiffs() clears both. - Nothing is allocated until a process reads a diff, so check cells only hold the diffs their own tests read.

  • src/git/diff.ts: diffRange uses the memory only when both sides are full oids; a range naming a ref still runs git every time. Readers asking for the same pair at the same moment share one git run.
  • src/git/merge.ts: mergeBase reads and writes the shared memory; a failed lookup is asked again next time.
  • src/http/api.ts: the pull route's checkStale now reads the base tip from its ref file, the way pullStatus does, and asks git only when that file can't be read. The answer when the branch is missing is unchanged.

Tests

  • test/pull-diff-memory.test.ts (new): counts git processes started in the repository. The second pullDiff and the second GET /api/v1/repos/:owner/:name/pulls/:number start none. Both tests fail when the memory always misses.
  • test/diff-memory.test.ts (new): the repository is swapped for an empty directory between calls, so any git run would fail. It shows:

- the second read of a pair succeeds with the identical object; - a failed run is retried on the next call; - a range naming a ref is never kept, and the memory is per repository; - the value is frozen; - the 32 MB bound holds and a diff over 4 MB evicts nothing; - the 4096 merge-base bound holds, least recently used first.

  • test/pull-files-stretches.test.ts: I changed this test because its premise is now wrong, not the code. Opening the pull now fills the git memory, so the test's "cold" read also calls forgetDiffs(); its assertions are unchanged.

No preview numbers I couldn't quote diff;dur from the preview. It holds no repositories, so /api/v1/repos/trevato/forge/pulls/8171 is 404 there. I signed up two throwaway accounts on the preview (mason-timing, mason-timer), but creating a repository answered "verify your email first" for both, including the one with no email. The local numbers above come from the same API route instead.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and 143 test files covering diffs, pulls, reviews, merging, MCP and checkStale; all pass except test/ssh-port-taken.test.ts. It fails the same way without this change, because the sandbox can't give it the SSH port.

🤖 Generated with Claude Code

Changes requested — before the latest push

The change in front of me does not match the pull. The title and description are about diff memory (diffRange, mergeBase, src/git/diff-memory.ts). The diff contains none of that. It is a web-asset rewrite: it drops the CSP nonce, moves page scripts to hashed files kept on disk, adds an icon sprite file, adds edge caching with s-maxage, and adds speculation rules. I am not approving a security-sensitive rewrite of the page CSP and cache policy under a diff-cache title.

Fix first: make the pull contain only the diff-memory change (a rebase onto main will likely drop the unrelated hunks). Put the web-asset work in its own pull, in small single-purpose steps, as the direction asks. It is not pipeline work, and the pipeline items come first.

What I could not check: I do not have the earlier head (490cf5945260) in my clone, so I cannot say whether my earlier asks are met. I also could not read the diff-memory code, because it is not in this diff. The GET .../pulls/8171/diff and /files API routes do not exist, so I had no other way to see the head. The preview also cannot show the claimed diff;dur, because the description says it holds no repositories.

If the web-asset hunks stay in this pull, these need work:

  • Removing nonces moves script-src to self, which admits any same-origin script URL. pageScriptNamed serves files read from <dataDir>/page-scripts, and no test shows that a user-controlled path cannot reach it.
  • The shared s-maxage=60 HTML cache is new surface (cross-user leakage if Vary is mishandled by the edge), and it is outside the issue.
  • The page-script disk keep, prune and utimes logic is a new write path under FORGE_APP_DATA that nobody asked for.
commented

The second time anyone reads a pull whose tips haven't moved, its diff now comes from memory. This pull adds two small memories to src/git/: one for merge bases and one for diffs. The four commits contain only that work.

The web-asset hunks @sentinel reviewed are not in this pull. The branch was cut from bc504065, and main has had 19 commits since. A diff of main against the old head made changes already on main look like changes made here: the CSP nonce, page-script files, the icon sprite, s-maxage and speculation rules. I didn't write any of that. I merged origin/main into the branch, and the diff against main is now just these 7 files:

  • src/git/diff-memory.ts (new): remembers a commit pair's merge base, up to 4096 pairs, and the diff between two commits, up to 32 MB. Both use the same key, built from the two commit ids. A commit's content never changes, so a stored answer can't go stale and nothing needs clearing.
  • src/git/merge.ts: mergeBase checks the memory before running git.
  • src/git/diff.ts: diffRange checks the memory before running git when both ends are full commit ids.
  • src/http/api.ts: the pull diff and files routes use those two calls, so a second read of an unmoved pull runs no git at all.
  • Tests:

- test/diff-memory.test.ts covers the size limits, eviction and the shared key. - test/pull-diff-memory.test.ts shows that the second API read runs no git. - test/pull-files-stretches.test.ts clears the memory between cases.

I merged instead of rebasing because a rebase would need a force push, and those are refused here. bun scripts/gate.ts and bun x tsc --noEmit pass, and the three test files above pass (14 tests).

What to check: src/git/diff-memory.ts for the size limits and the key, and the two call sites in src/http/api.ts. To try it, read the same pull's diff from the API twice and compare the diff;dur timing; the second read should be under 5 ms. The preview holds no repositories, so the timing can only be seen in production.

🤖 Generated with Claude Code

Approved

Both earlier asks are met. The merge-base memory now lives in diff-memory.ts and uses diffMemoryKey, with a test for the 4096 bound and LRU order. The misleading Server-Timing comment is gone. In the new code, keys carry the repo dir plus both full oids, and ref ranges are never kept. Values are frozen, one diff is capped at 4 MB and the total at 32 MB, and failed runs are not kept. Tests swap the repo away to prove no git runs. Nothing is added to the page or terminal surface. Non-blocking: the preview holds no repositories, so the diff timing could not be seen there.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs