Skip to content

Record a tree per diff snapshot so the Diff tab shows per-interval changes - #63

Merged
ilovecrayons merged 3 commits into
mainfrom
varram/sup-60-record-a-tree-per-diff-snapshot
Sep 4, 2026
Merged

Record a tree per diff snapshot so the Diff tab shows per-interval changes#63
ilovecrayons merged 3 commits into
mainfrom
varram/sup-60-record-a-tree-per-diff-snapshot

Conversation

@not-varram

Copy link
Copy Markdown
Contributor

Summary

Closes SUP-60. The Diff tab rendered one cumulative patch against the fork point and only filtered it to a snapshot's file list, so selecting a snapshot showed a file's state as of now rather than what that interval changed. Nothing recorded a tree per snapshot, so the per-interval delta was not obtainable.

  • Every diff snapshot now stages the run's worktree and writes a git tree into a per-run sidecar object store at <data>/checkouts/<run-id>.diffsnap/ (an objects/ directory whose alternates file points at the checkout's own database, a persistent index, and a last file). Nothing under the checkout's .git is written, since that directory belongs to the run's user. The store is removed with the checkout by the existing GC, and its size lands in the disk gauge's worktree_bytes.
  • run.diff events carry tree and parent_tree; the patch endpoint takes ?from=&to=, accepts only full-length hex ids that resolve to tree objects in that run's store, and answers run.patch: that snapshot's tree is no longer on disk once the checkout is gone.
  • The snapshot gate is the tree, so an edit that kept line counts identical is no longer dropped; after a restart the watch resumes from last.
  • The dashboard renders each interval and drops the filtered view and the "changed again since that snapshot" notice.

Validation

  • Real-git tests: two snapshots editing the same file render only the second change; a stale index.lock (a git add killed at the 30 s timeout) is cleared and the next snapshot succeeds; restart resumes the interval chain; a commit id and an all-zero id are refused; the checkout's .git is content-identical after snapshots and renders; a range request after checkout removal does not recreate the store.
  • make fmt-check vet lint test public-audit, go test -race -tags integration ./internal/gitengine/, and make test-integration (Docker) pass; cd web && bun run typecheck && bun run test (516 tests) pass.
  • Two fresh-context adversarial reviews: the first found the stale-lock failure, a gate that could publish an empty interval after restart, an overclaimed containment sentence (the existence check also let an all-zero id through) and unbounded loose objects; the second verified the fixes and returned MERGE with four low findings (a read path recreating deleted store state, alternates rewritten with O_TRUNC under a concurrent writer, an unbounded dashboard interval cache, an unretryable cached error). All fixed before opening.

🤖 Generated with Claude Code

https://claude.ai/code/session_019irxg6aWWKmn2w3QsJR5CJ

The Diff tab was meant to be a chronological list of unified diffs - what
changed in the last five minutes - but nothing recorded a tree per snapshot,
so it could only render one cumulative patch and filter it to a snapshot's
file list. A file edited at snapshot 1 and again at snapshot 3 showed its
state now under either, and a reverted file dropped out entirely behind the
notice "changed again since that snapshot".

The git engine now writes a tree per snapshot. Staging goes into a per-run
store at <checkouts>/<run-id>.diffsnap - a sibling of the checkout, the same
sidecar convention the identity record uses - holding an object directory
whose alternates file points at the checkout's own database, a persistent
index that carries git's stat cache between snapshots, and the last tree id
so the interval chain survives a watch or server restart. Nothing under the
checkout's .git is written: run provisioning chowned it to the run's user.
The store is removed with the checkout, so the trees are reclaimed by the
same scheduler GC that already reclaims worktrees and nothing accrues after
a run is cleaned up.

run.diff carries the snapshot's tree and the tree before it, and run.patch
takes from and to to render that one interval instead of the cumulative
diff. Client-supplied ids are validated as object ids and resolved only
inside the run's own store. The snapshot gate is now the tree with the stat
set as fallback, which also closes a hole: an edit that kept line counts
identical moved the tree but not the numstat and was dropped silently.

The dashboard renders the interval on selection and caches each one - two
tree ids address it, so it can never go stale. The filtered view and its
notice are gone; a snapshot with no tree is not selectable.
…anges

Review findings on the per-interval diff work.

A persistent index means a persistent index.lock. Git killed at the 30s
snapshot timeout, or a server that died mid-staging, would leave one behind
and every later snapshot for that run would fail with "Unable to create
index.lock: File exists" until checkout GC - per-interval diffs dead, no
error anywhere the member can see. The store is single-writer: one watch
goroutine stages into it, and RemoveRunCheckout stops that watch before
deleting. Any lock found is stale by construction, so staging clears it
first.

The publish gate ORed tree inequality with stat inequality, which the
comment and the commit message both described as tree-with-stats-as-
fallback. It matters after a restart: lastTree is restored from the store
but the stat set starts empty, so the first snapshot of unchanged content
published an interval whose two ends are the same tree. The tree decides
now, and the stats are the fallback only when the tree could not be written.

A range end was resolved with rev-parse id^{tree}, so a commit id from the
history the checkout was cloned with peeled to its tree and rendered a diff
the timeline never offered. Ends must now name a tree object. Existence is
checked with ^{object}, because rev-parse --verify on a bare full-length id
echoes it back without asking the database anything - the all-zero id sailed
through.

Also: the last-tree record is written through a temporary file and renamed,
so a crash leaves the previous id rather than a truncated one; a tree-to-
tree render no longer seeds an index it never writes, which keeps a read
clear of the lock staging takes; and docs/install.md says how the store
grows.

Tests cover a planted stale lock, a watch restart resuming its chain, a
commit id refused as a range end, and the snapshot path leaving the
checkout's .git untouched.
Re-review findings on the per-interval diff.

Rendering a range no longer brings a snapshot store into being. A request
racing checkout GC would recreate the directory the removal had just
deleted, and nothing collects it afterwards; the store is stat'd first and a
missing one answers "tree is no longer on disk", which is what it is.

The alternates file is now written only when it is missing or wrong, and
installed by rename. Every range request used to rewrite it with O_TRUNC
while the watch goroutine was doing the same, and a git that opened it in
that window saw no alternate at all and reported the run's own trees as
gone.

On the dashboard, the interval cache was described as bounded by the
snapshot list but nothing pruned it: trimming the list now drops the patches
its dropped snapshots named. A cached failure is now retryable too - Refresh
clears the failed entries, which were otherwise the one thing on the tab no
button could recover, while intervals that answered stay, being immutable.

listTree digests each file's content, so the assertion that a render leaves
the checkout's .git alone can see an in-place rewrite of .git/index rather
than only an added or removed path.
@not-varram
not-varram force-pushed the varram/sup-60-record-a-tree-per-diff-snapshot branch from 96f177e to ce49c31 Compare September 4, 2026 03:59
@ilovecrayons
ilovecrayons merged commit 7390130 into main Sep 4, 2026
6 checks passed
@not-varram
not-varram deleted the varram/sup-60-record-a-tree-per-diff-snapshot branch September 4, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants