steid

@jamesgill /

docs: what the next session should not have to rediscover about diffs

Not `plans/` itself — five agents appending to `current.md` at once would
conflict every time, so the wave's convention is a handover file the
orchestrator folds in at merge time.

Records the four decisions worth keeping (one process for counts and patch,
the capped pipe read, the `merge-base` exit-code exception, three-dot
compare), the process cost per page, and the holes: the timeout state has
never been seen, a merge is diffed against its first parent only, and a
truncated diff discards the files that did fit rather than showing half a
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YK7E9aAvUqjNUmZjTebg8g
JamesPatrickGill authored 15 hours agoparent2268309Browse files059d617b9adc424dfa707cbbf97ec4eb3abcfcba

1 file changed+162 −0

plans/handover-commits.md+162 −0View file
@@ -0,0 +1,162 @@
1+# Handover — `commits`: the commit page and the compare view
2+
3+Written by the `commits` agent of the section 1 wave, for the orchestrator to fold into
4+`progress.md`, `current.md` and `ui.md`. Branch `feat/commit-page`.
5+
6+## What shipped
7+
8+Three routes, all read-only, all authorized through the existing `view_repo`:
9+
10+| URL | What it is |
11+|---|---|
12+| `/{handle}/repos/{name}/commits/{sha}` | one commit and its diff |
13+| `/{handle}/repos/{name}/compare` | the form asking which two revisions |
14+| `/{handle}/repos/{name}/compare/{base}...{head}` | the comparison, three-dot |
15+
16+New files: `src/application/diff.rs` (pure parsing), `src/application/commit.rs` (two use
17+cases), `src/infrastructure/web/commit.rs` (three pages and the diff renderer). Four new
18+`GitQuery` methods, one new domain type (`CommitDetail`), five plain-CSS rules in
19+`styles.css`.
20+
21+Every sha in the log and the sha in the landing page's latest-commit bar now link to the
22+commit page — the "nothing links to a commit by its sha yet" item in `current.md` is
23+closed.
24+
25+## progress.md material — the decisions
26+
27+**The counts and the patch come from one `git` process.** `git diff-tree --numstat -p`
28+writes the `--numstat` block *before* the patch. Asking for both in the same run means
29+the per-file counts survive the byte cap a very large diff hits: a commit whose patch
30+cannot be drawn still lists every changed file with real numbers. A second `--numstat`
31+call would have been paid on every commit page to buy something only the rare oversized
32+one needs. Measured alternative rejected: deriving counts by summing the patch's `+`/`-`
33+lines is exact for text but produces nothing at all once the patch is truncated, which is
34+the case the file list exists for.
35+
36+**`run_capped` reads through a pipe and kills the child.** `run` uses `Command::output`,
37+which collects everything git writes. That is right for a tree listing and wrong for a
38+patch — one commit can carry hundreds of megabytes of diff, and a page must not be able
39+to pull that into memory. `run_capped` reads `max_bytes + 1` and `start_kill()`s. **The
40+exit status is checked only when the cap was not hit**, because a killed process has a
41+status that says so.
42+
43+**`merge-base` is the one command allowed a non-zero exit**, via `run_allowing`. The
44+module's rule is that a non-zero exit is always a fault, because git's not-found codes
45+collide with its error codes at other call sites. `git merge-base` is the exception git's
46+own manual documents: 1 means "no common ancestor", 128 means broken. Two unrelated
47+histories in one repository is a state a compare page must be able to state rather than
48+500 on. **Adding a second caller to `run_allowing` deserves the same scrutiny** — the
49+question is whether git's manual promises the code, not whether it happens to work.
50+
51+**Compare is three-dot**, diffing merge-base-to-head. A two-dot diff also undoes
52+everything the base gained since the branch left it, which reads as a wall of deletions
53+nobody made. It is also exactly what a pull request will need, so it is settled now rather
54+than when pull requests arrive.
55+
56+**The patch parser tracks hunk line counts rather than matching prefixes anywhere.** A
57+patch *of a patch* contains lines reading `diff --git …`, `+++ b/…` and `--- a/…` as
58+ordinary content. A parser that matched those prefixes anywhere splits one file into
59+several and attributes the rest of the commit to a file that does not exist. `@@ -a,b
60++c,d @@`'s two lengths are what tell the parser whether it is inside a hunk.
61+
62+**Paths come from `---`/`+++`/`rename from`, not from the `diff --git` header.** The
63+header is `a/<old> b/<new>` with a space between two names that may themselves contain
64+spaces, and nothing in the format says where the split is. The header is a first guess and
65+every later line overrides it. C-style quoting (`"docs/caf\303\251.md"`) is unquoted, or
66+a link would carry a name that is not the file's name.
67+
68+**The compare form GETs to `/compare?base=…&head=…` and that page redirects (307) to the
69+path form.** A GET form submits query parameters, not path segments, and Steid does not
70+require JavaScript. The shareable URL is therefore always the path one, which is what the
71+branches page's contract needs.
72+
73+**`...` is unambiguous as a separator** because `RefName` refuses `..` outright, so a
74+revision cannot contain two consecutive dots. Each half is percent-encoded on its own, so
75+`feature/login` stays inside its half.
76+
77+**The file header is `sticky`, which is why the panel is not `overflow-hidden`.** A hidden
78+overflow makes an ancestor a scroll container and sticky then sticks to a box that never
79+scrolls — which looks exactly like sticky being broken. The corners are rounded on the
80+children instead.
81+
82+## Process cost
83+
84+| Page | `git` processes |
85+|---|---|
86+| commit | **3** — resolve, read the commit, diff |
87+| compare, path form | **5** — two resolves (concurrent), merge base, then log range and diff (concurrent) → three round trips |
88+| compare, empty form | **2** — `list_refs` for the datalist, `default_branch` to prefill the base |
89+
90+## current.md material — holes opened and shortcuts taken
91+
92+- **The timeout state is compiled but has never been seen.** `unwrap_page` turns a
93+ `GitQueryError::TimedOut` into a rendered "This took too long to read" panel rather
94+ than a 500, which is what the brief asked for, but no repository here is large enough
95+ to spend twenty seconds in `diff-tree`. It is the one designed state with no screenshot.
96+- **`Diff::files` is emptied wholesale when the patch is truncated.** Half a patch is not
97+ a smaller patch, so the page falls back to the numstat file list. The files that *did*
98+ fit are discarded rather than shown — deliberate, but it means a 10 MiB commit shows no
99+ lines at all rather than the first few files' worth.
100+- **numstat itself could in principle be truncated.** It is written first and is ~30 bytes
101+ per file, so it takes ~300,000 changed files in one commit to reach the 10 MiB cap
102+ before the patch begins. Not defended against.
103+- **A merge commit is diffed against its first parent only**, which is the right answer
104+ for "what did this commit change" but means a merge that resolved conflicts by hand
105+ shows only the first-parent view. No fixture here contains a merge, so **merge
106+ rendering was compiled, not seen** — the parent list renders two links by construction.
107+- **`MAX_RAW_BYTES` (10 MiB) is now doing a second job.** It was chosen as "how much of a
108+ repository may one request hold in memory" for raw blobs; the diff reuses it. If it is
109+ ever retuned for blobs, the diff cap moves with it.
110+- **`bg-surface` is nearly `bg-background` in light mode** (`oklch(1 0 0)` vs
111+ `oklch(0.99 …)`), so a file header reads only by its border there. Not introduced by
112+ this step — the empty-repository `<pre>` has the same property — but this page has one
113+ such header per file, which makes it the most visible instance.
114+- **The `/log` page still shows at most 50 commits with no paging**, and a comparison
115+ shows at most 100. Both now say so; neither has a next page.
116+- **The compare form's `<datalist>` costs a `list_refs`.** For a repository with hundreds
117+ of refs that is a large `<option>` list rendered inline on every visit to the form.
118+
119+## ui.md material
120+
121+The entry-point table's rows for **"A commit page — the sha in the latest-commit bar and
122+in the log"** and **"Compare — reached from a branch row on the branches page"** are now
123+filled in. Compare is *also* reachable directly at `/compare`, which the table did not
124+anticipate: a branch row is the shortcut, not the only door.
125+
126+Notes worth adding under "The repository page":
127+
128+- **A commit page is a Commits-tab page**, and its header's Commits link points at the log
129+ *at that commit's own id*, not at the revision the URL used. A branch name there would
130+ send someone to a different commit tomorrow.
131+- **A compare page is a Code-tab page.** It is about two revisions of the code, not about
132+ the history, and it is reached from the branches page.
133+- **The success and destructive tokens appear as text in exactly three places**: the
134+ commit's `+a −b` summary, each file header's `+a −b`, and the truncated file list. The
135+ diff line tints are the same tokens at 12% (rows) and 20% (gutters) through
136+ `color-mix`, as plain CSS in `styles.css` — Tailwind has no utility for a token at an
137+ alpha, and a literal colour would follow neither a palette change nor the colour scheme.
138+- **The primary colour is used only for links out of a dead end**: "View the whole file"
139+ under a truncated file, and "Compare them the other way round" when the head is behind.
140+ Nothing else on these pages is coloured.
141+- **Every state is designed**: no files changed, binary, a rename, a file with no line
142+ changes, a file over 1,000 lines, a diff over the byte cap, identical revisions,
143+ unrelated histories, a head already contained in its base, an unknown ref (which
144+ re-renders the form with the reason rather than 404ing), and a timeout.
145+
146+## Verification
147+
148+`cargo test` — 507 pass. `cargo clippy --all-targets` — zero warnings. `cargo fmt` clean.
149+
150+Run on port 3101 against a `steid` repository holding this repository's own history, two
151+annotated tags, and a `fixtures` branch built for this step: a commit with a rename, a
152+binary change, an addition and a deletion; a commit adding a 5,000-line file; and a commit
153+adding 15 MB of text. Checked in the browser: both colour modes on the commit page, an
154+800px viewport with no horizontal page overflow (long lines scroll inside their own
155+container), the sticky file header, all three compare states, the two truncation states,
156+a branch name containing a slash through the compare URL, and 404 for an anonymous
157+visitor on every one of these routes in a private repository.
158+
159+**Not verified:** the timeout state, a merge commit, and a repository whose refs are not
160+UTF-8.
161+
162+Screenshots are in the wave's shots directory, prefixed `commits-`.