Skip to content

feat: compare any two commits or tags side by side - #4

Merged
renrenmimi merged 3 commits into
mainfrom
feat/compare-two-points
Aug 31, 2026
Merged

renrenmimi merged 3 commits into
mainfrom
feat/compare-two-points

Conversation

@renrenmimi

Copy link
Copy Markdown
Owner

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.

Column Contents
Net file changes the per-file difference, with expandable diffs
Commits in between the commits that produced it, clickable back into the timeline
Side by side the two ends measured against each other

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 sentence
carrying the same numbers.

Swapping the ends reads the difference backwards and says behind. A comparison
owns the address bar while it is open (?repo=…&base=…&head=…), so a shared link
opens 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 is
looking 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_commit but never a head commit, and
when 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 difference
is 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/inHead already
describe 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 a
plain method to a button, so this.#state threw on click and swapping silently
did 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

Check Result
tsc --noEmit clean
eslint . clean
vitest run 496 passed (was 460)
next build clean
playwright test 157 passed (was 141)
privacy audit 3/3 pass

New 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 (0
files changed under .github/), src/lib/github/client.ts is untouched so the
server-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

renrenmimi and others added 3 commits August 31, 2026 12:32
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>
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
repo-time-machine Ready Ready Preview Aug 31, 2026 7:52pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/lib/compare/local.ts
Comment on lines +65 to +71
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@renrenmimi
renrenmimi merged commit 663558a into main Aug 31, 2026
4 checks passed
@renrenmimi
renrenmimi deleted the feat/compare-two-points branch August 31, 2026 19:58

This branch was successfully deployed

1 active deployment
Preview — c7a63b92 Deployed Aug 31, 2026 by vercel[bot]
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.

1 participant