# Handover — `commits`: the commit page and the compare view

Written by the `commits` agent of the section 1 wave, for the orchestrator to fold into
`progress.md`, `current.md` and `ui.md`. Branch `feat/commit-page`.

## What shipped

Three routes, all read-only, all authorized through the existing `view_repo`:

| URL | What it is |
|---|---|
| `/{handle}/repos/{name}/commits/{sha}` | one commit and its diff |
| `/{handle}/repos/{name}/compare` | the form asking which two revisions |
| `/{handle}/repos/{name}/compare/{base}...{head}` | the comparison, three-dot |

New files: `src/application/diff.rs` (pure parsing), `src/application/commit.rs` (two use
cases), `src/infrastructure/web/commit.rs` (three pages and the diff renderer). Four new
`GitQuery` methods, one new domain type (`CommitDetail`), five plain-CSS rules in
`styles.css`.

Every sha in the log and the sha in the landing page's latest-commit bar now link to the
commit page — the "nothing links to a commit by its sha yet" item in `current.md` is
closed.

## progress.md material — the decisions

**The counts and the patch come from one `git` process.** `git diff-tree --numstat -p`
writes the `--numstat` block *before* the patch. Asking for both in the same run means
the per-file counts survive the byte cap a very large diff hits: a commit whose patch
cannot be drawn still lists every changed file with real numbers. A second `--numstat`
call would have been paid on every commit page to buy something only the rare oversized
one needs. Measured alternative rejected: deriving counts by summing the patch's `+`/`-`
lines is exact for text but produces nothing at all once the patch is truncated, which is
the case the file list exists for.

**`run_capped` reads through a pipe and kills the child.** `run` uses `Command::output`,
which collects everything git writes. That is right for a tree listing and wrong for a
patch — one commit can carry hundreds of megabytes of diff, and a page must not be able
to pull that into memory. `run_capped` reads `max_bytes + 1` and `start_kill()`s. **The
exit status is checked only when the cap was not hit**, because a killed process has a
status that says so.

**`merge-base` is the one command allowed a non-zero exit**, via `run_allowing`. The
module's rule is that a non-zero exit is always a fault, because git's not-found codes
collide with its error codes at other call sites. `git merge-base` is the exception git's
own manual documents: 1 means "no common ancestor", 128 means broken. Two unrelated
histories in one repository is a state a compare page must be able to state rather than
500 on. **Adding a second caller to `run_allowing` deserves the same scrutiny** — the
question is whether git's manual promises the code, not whether it happens to work.

**Compare is three-dot**, diffing merge-base-to-head. A two-dot diff also undoes
everything the base gained since the branch left it, which reads as a wall of deletions
nobody made. It is also exactly what a pull request will need, so it is settled now rather
than when pull requests arrive.

**The patch parser tracks hunk line counts rather than matching prefixes anywhere.** A
patch *of a patch* contains lines reading `diff --git …`, `+++ b/…` and `--- a/…` as
ordinary content. A parser that matched those prefixes anywhere splits one file into
several and attributes the rest of the commit to a file that does not exist. `@@ -a,b
+c,d @@`'s two lengths are what tell the parser whether it is inside a hunk.

**Paths come from `---`/`+++`/`rename from`, not from the `diff --git` header.** The
header is `a/<old> b/<new>` with a space between two names that may themselves contain
spaces, and nothing in the format says where the split is. The header is a first guess and
every later line overrides it. C-style quoting (`"docs/caf\303\251.md"`) is unquoted, or
a link would carry a name that is not the file's name.

**The compare form GETs to `/compare?base=…&head=…` and that page redirects (307) to the
path form.** A GET form submits query parameters, not path segments, and Steid does not
require JavaScript. The shareable URL is therefore always the path one, which is what the
branches page's contract needs.

**`...` is unambiguous as a separator** because `RefName` refuses `..` outright, so a
revision cannot contain two consecutive dots. Each half is percent-encoded on its own, so
`feature/login` stays inside its half.

**The file header is `sticky`, which is why the panel is not `overflow-hidden`.** A hidden
overflow makes an ancestor a scroll container and sticky then sticks to a box that never
scrolls — which looks exactly like sticky being broken. The corners are rounded on the
children instead.

## Process cost

| Page | `git` processes |
|---|---|
| commit | **3** — resolve, read the commit, diff |
| compare, path form | **5** — two resolves (concurrent), merge base, then log range and diff (concurrent) → three round trips |
| compare, empty form | **2** — `list_refs` for the datalist, `default_branch` to prefill the base |

## current.md material — holes opened and shortcuts taken

- **The timeout state is compiled but has never been seen.** `unwrap_page` turns a
  `GitQueryError::TimedOut` into a rendered "This took too long to read" panel rather
  than a 500, which is what the brief asked for, but no repository here is large enough
  to spend twenty seconds in `diff-tree`. It is the one designed state with no screenshot.
- **`Diff::files` is emptied wholesale when the patch is truncated.** Half a patch is not
  a smaller patch, so the page falls back to the numstat file list. The files that *did*
  fit are discarded rather than shown — deliberate, but it means a 10 MiB commit shows no
  lines at all rather than the first few files' worth.
- **numstat itself could in principle be truncated.** It is written first and is ~30 bytes
  per file, so it takes ~300,000 changed files in one commit to reach the 10 MiB cap
  before the patch begins. Not defended against.
- **A merge commit is diffed against its first parent only**, which is the right answer
  for "what did this commit change" but means a merge that resolved conflicts by hand
  shows only the first-parent view. No fixture here contains a merge, so **merge
  rendering was compiled, not seen** — the parent list renders two links by construction.
- **`MAX_RAW_BYTES` (10 MiB) is now doing a second job.** It was chosen as "how much of a
  repository may one request hold in memory" for raw blobs; the diff reuses it. If it is
  ever retuned for blobs, the diff cap moves with it.
- **`bg-surface` is nearly `bg-background` in light mode** (`oklch(1 0 0)` vs
  `oklch(0.99 …)`), so a file header reads only by its border there. Not introduced by
  this step — the empty-repository `<pre>` has the same property — but this page has one
  such header per file, which makes it the most visible instance.
- **The `/log` page still shows at most 50 commits with no paging**, and a comparison
  shows at most 100. Both now say so; neither has a next page.
- **The compare form's `<datalist>` costs a `list_refs`.** For a repository with hundreds
  of refs that is a large `<option>` list rendered inline on every visit to the form.

## ui.md material

The entry-point table's rows for **"A commit page — the sha in the latest-commit bar and
in the log"** and **"Compare — reached from a branch row on the branches page"** are now
filled in. Compare is *also* reachable directly at `/compare`, which the table did not
anticipate: a branch row is the shortcut, not the only door.

Notes worth adding under "The repository page":

- **A commit page is a Commits-tab page**, and its header's Commits link points at the log
  *at that commit's own id*, not at the revision the URL used. A branch name there would
  send someone to a different commit tomorrow.
- **A compare page is a Code-tab page.** It is about two revisions of the code, not about
  the history, and it is reached from the branches page.
- **The success and destructive tokens appear as text in exactly three places**: the
  commit's `+a −b` summary, each file header's `+a −b`, and the truncated file list. The
  diff line tints are the same tokens at 12% (rows) and 20% (gutters) through
  `color-mix`, as plain CSS in `styles.css` — Tailwind has no utility for a token at an
  alpha, and a literal colour would follow neither a palette change nor the colour scheme.
- **The primary colour is used only for links out of a dead end**: "View the whole file"
  under a truncated file, and "Compare them the other way round" when the head is behind.
  Nothing else on these pages is coloured.
- **Every state is designed**: no files changed, binary, a rename, a file with no line
  changes, a file over 1,000 lines, a diff over the byte cap, identical revisions,
  unrelated histories, a head already contained in its base, an unknown ref (which
  re-renders the form with the reason rather than 404ing), and a timeout.

## Verification

`cargo test` — 507 pass. `cargo clippy --all-targets` — zero warnings. `cargo fmt` clean.

Run on port 3101 against a `steid` repository holding this repository's own history, two
annotated tags, and a `fixtures` branch built for this step: a commit with a rename, a
binary change, an addition and a deletion; a commit adding a 5,000-line file; and a commit
adding 15 MB of text. Checked in the browser: both colour modes on the commit page, an
800px viewport with no horizontal page overflow (long lines scroll inside their own
container), the sticky file header, all three compare states, the two truncation states,
a branch name containing a slash through the compare URL, and 404 for an anonymous
visitor on every one of these routes in a private repository.

**Not verified:** the timeout state, a merge commit, and a repository whose refs are not
UTF-8.

Screenshots are in the wave's shots directory, prefixed `commits-`.
