feat: compare any two commits or tags side by side - #4
Conversation
Comparing two points of a history needs the same answer from three places: a live repository, the built-in demo, and the recorded fixtures. They now share one shape, `RepoCompare`, so nothing downstream has to know which produced it. - `src/lib/compare/local.ts` compares two points of a history already in memory. Where a tree is known at both ends it reads the net difference off the two trees rather than replaying diffs, which stays exact however many times a file was touched in between; where a tree is missing it falls back to the recorded diffs and reports the result as truncated. - The built-in demo stores a tree at every commit, so it always takes the exact branch, locally, with no GitHub request. - For a live repository one request to GitHub's compare endpoint returns how the two points relate, the commits between them and the net per-file difference. Its one gap is head: the payload has no `head_commit`, and when head is an ancestor of base the commit list is empty, so a second very small request identifies it instead of leaving the interface with a bare sha. A patch is only attached to a net difference when exactly one recorded hunk *is* that difference. Otherwise `patchOmittedReason` is `aggregated`, because showing one commit's hunk as the whole change would be a lie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second view alongside the timeline. The timeline answers "what did this commit do"; this answers "what is different between these two points", which needs the net difference rather than the sum of the commits in between. - Two pickers choose either end. Tags come first, because comparing two releases is the question people arrive with; every commit in the loaded range is below, searchable by subject, sha or author. - The summary states the relation, how far ahead and behind, and the totals, with a screen-reader sentence carrying the same numbers. - Three columns: the net file changes with expandable diffs, the commits in between, and the two ends measured against each other — file counts, tracked bytes, top-level folders, which folders appeared or went, and the file-type mix on a shared colour scale. - Swapping the ends reads the difference backwards, and says `behind`. - A comparison owns the address bar while it is open (`?base=…&head=…`), so a shared link opens straight into it and Back returns to the timeline. Nothing is summarised or interpreted: every number shown is counted from a tree or a diff, and where one cannot be it says so instead. The file-change list is now shared with the commit view rather than duplicated, and the four compare callbacks are bound methods, because they are handed straight to buttons — a detached `swapCompare` would otherwise lose `this`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers what the view shows, where each number comes from for a live repository and for local data, and the one thing it deliberately does not do: summarise. Also states the consequence of that honestly — a file touched more than once between the two points reports only its summed line counts, because no single recorded hunk is the net difference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7a63b9229
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // The two views own the address bar in turn, never at the same time. | ||
| if (compareOpen && compareBase && compareHead) { | ||
| writeUrl(state.ref, null, 'replace', { base: compareBase, head: compareHead }); |
There was a problem hiding this comment.
Preserve the timeline entry when opening compare
When a user opens Compare from the timeline, this unconditional replaceState overwrites the timeline's current history entry. Pressing the browser Back button then leaves the repository or returns home instead of returning to the timeline, contrary to the documented navigation behavior. Push an entry for the closed-to-open transition, while continuing to replace it for subsequent endpoint changes.
Useful? React with 👍 / 👎.
| const names = [file.path, file.previousPath].filter((value): value is string => Boolean(value)); | ||
| for (const path of names) { | ||
| const entry: Touch = | ||
| touches.get(path) ?? | ||
| { additions: 0, deletions: 0, patches: [], lastStatus: file.status, previousPath: null }; | ||
| entry.additions += file.additions; | ||
| entry.deletions += file.deletions; |
There was a problem hiding this comment.
Count rename statistics only once
For a local comparison spanning a rename, this loop adds the rename's additions and deletions to both the destination path and previousPath. Since the endpoint snapshots subsequently emit both paths as separate changes, the summary double-counts the rename; for example, the built-in demo's lesson-card.tsx rename records +12/-8 but a comparison spanning it contributes +24/-16. Track the old name for path matching without accumulating the same statistics twice.
Useful? React with 👍 / 👎.
| type SideSummary = { files: number; bytes: number; areas: Set<string>; languages: Set<string> }; | ||
|
|
||
| function summarise(projection: Projection | null): SideSummary | null { | ||
| if (!projection || projection.files.size === 0) return null; |
There was a problem hiding this comment.
Treat an empty projection as a loaded tree
When an endpoint is a valid commit whose tree contains zero files, projection.files.size === 0 makes summarise return null. The side-by-side view consequently reports that the tree was not loaded and displays dashes instead of the accurate zero counts. Only a null projection should mean unavailable; an empty file map is a valid repository state.
Useful? React with 👍 / 👎.
What this adds
A second view alongside the timeline: pick any two commits — or any two tags —
and see what is different between them.
The timeline answers what did this commit do. This answers what is different
between these two points, which is not the same question. It needs the net
difference, not the sum of the commits in between: a file touched five times and
then reverted has not changed at all.
What it shows
Two pickers choose either end. Tags come first in both, because comparing two
releases is the case this was built for; every commit in the loaded range is
below, searchable by subject, sha or author.
The side-by-side column reports file counts, tracked bytes, top-level folders,
which folders appeared or disappeared, and the file-type mix of both ends on a
shared colour scale. The summary above states the relation (
ahead,behind,identical,diverged), how far, and the totals — with a screen-reader sentencecarrying the same numbers.
Swapping the ends reads the difference backwards and says
behind. A comparisonowns the address bar while it is open (
?repo=…&base=…&head=…), so a shared linkopens straight into it and Back returns to the timeline.
No summarising
There is no generated prose about a comparison. Every number is counted from a
tree or a diff, and anything that cannot be counted says so instead of being
estimated into a sentence.
One consequence is worth stating plainly. A diff is only attached to a net change
when exactly one recorded hunk is that change. If a file was touched more than
once between the two points, the file reports that and shows only the summed line
counts, because showing one commit's hunk as the whole difference would be wrong.
Where the numbers come from
Three sources, one shape (
RepoCompare), so nothing downstream knows which it islooking at.
A live repository — one request to GitHub's compare endpoint returns the
relation, the commits and the net file list together. Addressed by two shas, so it
never changes and is cached for a day.
That payload has one gap: it describes
base_commitbut never a head commit, andwhen head is an ancestor of base the commit list is empty, so there is nothing in
it to describe head with. A second very small request identifies head rather than
leaving a bare sha on screen. When even that fails the sha is shown alone, not
invented.
The built-in demo and the recorded fixtures — computed locally, in
src/lib/compare/local.ts. Where a tree is known at both ends the net differenceis read off those two trees rather than replayed from diffs, which stays exact
however many times a file was touched in between. Where a tree is missing at one
end it falls back to the loaded diffs and reports the file list as incomplete.
The demo stores a tree at every commit, so it always takes the exact branch —
still zero GitHub requests, verified in the e2e suite by counting requests
that left for github.com.
Two bugs found while building it
A double status reversal. In the exact branch,
inBase/inHeadalreadydescribe the requested direction, so reversing the status again for a backwards
comparison turned every addition into a removal. The line counts do need
reversing, because touches are collected forwards through time; the status does
not. Caught by a test that compares a range in both directions and asserts they
mirror.
A detached method losing
this.onSwap={controller.swapCompare}handed aplain method to a button, so
this.#statethrew on click and swapping silentlydid nothing. The four compare callbacks are now bound properties, and a test
destructures all four off the controller before calling them — which is exactly
how a button receives them.
Verification
tsc --noEmiteslint .vitest runnext buildplaywright testNew unit tests cover the net difference from two trees, a file changed and
changed back reporting nothing, line counts summed across a range, the patch rule
in both directions, the degraded path when a tree is missing, tag labelling,
abbreviated shas, and the adapter against a payload that cannot identify head.
New e2e tests cover opening from the timeline, the default pair being the last
release, the URL round-trip, comparing two tags, filtering the picker, swapping,
identical ends, Escape closing a picker without leaving the view, returning to the
timeline, one H1 naming the view, no horizontal overflow at 360px with a picker
open, and zero GitHub requests throughout the demo path.
Unchanged
The badge still reads
0 GitHub requests, the CI workflow is untouched (0files changed under
.github/),src/lib/github/client.tsis untouched so theserver-only token architecture is byte-identical, and the built-in demo is still
exactly 16 commits.
The file-change list is now shared between the commit view and the comparison
rather than duplicated; the commit view renders identically.
🤖 Generated with Claude Code