steid

@jamesgill /

docs: fold the commit page and compare handover into plans

Also records that the wave produced two run_allowing helpers — grep's and
merge-base's — and the merge kept one, with both exit codes named as constants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGBYVHV86DDvJCBKsDFHT6
JamesPatrickGill authored 15 hours agoparent059d617Browse files64f6f0d88729a24af6c4ee41803b442a4b0d3642

4 files changed+55 −167

plans/current.md+15 −3View file
@@ -169,6 +169,21 @@ instance, and Steid's own source is pushed to it and browsable there.
169169 query, cuts matched lines at 500 characters (which can remove the match on a minified
170170 line), and ignores `GrepHit::column` when marking.
171171
172+- **The commit page's timeout state has never been rendered**, like search's and the
173+ refs pages'. Three pages now each map `is_timeout()` to a panel of their own; the next
174+ one should move the helper to `web::context`.
175+- **`Diff::files` is emptied wholesale when the patch is truncated**, falling back to the
176+ numstat list, so a 10 MiB commit shows no lines rather than the first few files.
177+ numstat itself could in principle be truncated at ~300,000 changed files.
178+- **A merge commit is diffed against its first parent only**, and merge rendering was
179+ compiled, not seen: no fixture contains one.
180+- **`MAX_RAW_BYTES` now does a second job** as the diff cap; retune both together.
181+- **`bg-surface` is nearly `bg-background` in light mode**, so a file header reads only
182+ by its border there. Pre-existing, but the commit page has one per file.
183+- **No paging anywhere in history**: `/log` shows 50, a comparison 100, both say so.
184+- **The compare form's `<datalist>` costs a `list_refs`** rendered inline on every visit.
185+- ~~Nothing links to a commit by its sha yet~~ — done.
186+
172187 ### Carried over — small, unblocked
173188
174189 - **A client that disappears mid-request leaves the body-copy task waiting.** The copy
@@ -229,9 +244,6 @@ instance, and Steid's own source is pushed to it and browsable there.
229244 BSD-3 differ only by a third clause about 900 bytes in. `LICENSE-MIT` and other
230245 suffixed spellings are not detected: the filename list is deliberately short, because
231246 every extra candidate is another speculative blob read.
232- **Nothing links to a commit by its sha yet.** The latest-commit bar's sha points at
233 the log, which is the only page that shows the commit in context. It becomes a link
234 to the commit page the moment there is one.
235247 - **A per-file last-commit column is still absent**, deliberately — see
236248 [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
237249 Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen
plans/handover-commits.md+0 −162
@@ -1,162 +0,0 @@
1# Handover — `commits`: the commit page and the compare view
2
3Written 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
8Three 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
16New files: `src/application/diff.rs` (pure parsing), `src/application/commit.rs` (two use
17cases), `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
21Every sha in the log and the sha in the landing page's latest-commit bar now link to the
22commit page — the "nothing links to a commit by its sha yet" item in `current.md` is
23closed.
24
25## progress.md material — the decisions
26
27**The counts and the patch come from one `git` process.** `git diff-tree --numstat -p`
28writes the `--numstat` block *before* the patch. Asking for both in the same run means
29the per-file counts survive the byte cap a very large diff hits: a commit whose patch
30cannot be drawn still lists every changed file with real numbers. A second `--numstat`
31call would have been paid on every commit page to buy something only the rare oversized
32one needs. Measured alternative rejected: deriving counts by summing the patch's `+`/`-`
33lines is exact for text but produces nothing at all once the patch is truncated, which is
34the case the file list exists for.
35
36**`run_capped` reads through a pipe and kills the child.** `run` uses `Command::output`,
37which collects everything git writes. That is right for a tree listing and wrong for a
38patch — one commit can carry hundreds of megabytes of diff, and a page must not be able
39to pull that into memory. `run_capped` reads `max_bytes + 1` and `start_kill()`s. **The
40exit status is checked only when the cap was not hit**, because a killed process has a
41status that says so.
42
43**`merge-base` is the one command allowed a non-zero exit**, via `run_allowing`. The
44module's rule is that a non-zero exit is always a fault, because git's not-found codes
45collide with its error codes at other call sites. `git merge-base` is the exception git's
46own manual documents: 1 means "no common ancestor", 128 means broken. Two unrelated
47histories in one repository is a state a compare page must be able to state rather than
48500 on. **Adding a second caller to `run_allowing` deserves the same scrutiny** — the
49question 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
52everything the base gained since the branch left it, which reads as a wall of deletions
53nobody made. It is also exactly what a pull request will need, so it is settled now rather
54than when pull requests arrive.
55
56**The patch parser tracks hunk line counts rather than matching prefixes anywhere.** A
57patch *of a patch* contains lines reading `diff --git …`, `+++ b/…` and `--- a/…` as
58ordinary content. A parser that matched those prefixes anywhere splits one file into
59several 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
63header is `a/<old> b/<new>` with a space between two names that may themselves contain
64spaces, and nothing in the format says where the split is. The header is a first guess and
65every later line overrides it. C-style quoting (`"docs/caf\303\251.md"`) is unquoted, or
66a 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
69path form.** A GET form submits query parameters, not path segments, and Steid does not
70require JavaScript. The shareable URL is therefore always the path one, which is what the
71branches page's contract needs.
72
73**`...` is unambiguous as a separator** because `RefName` refuses `..` outright, so a
74revision 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
78overflow makes an ancestor a scroll container and sticky then sticks to a box that never
79scrolls — which looks exactly like sticky being broken. The corners are rounded on the
80children 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
121The entry-point table's rows for **"A commit page — the sha in the latest-commit bar and
122in the log"** and **"Compare — reached from a branch row on the branches page"** are now
123filled in. Compare is *also* reachable directly at `/compare`, which the table did not
124anticipate: a branch row is the shortcut, not the only door.
125
126Notes 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
150Run on port 3101 against a `steid` repository holding this repository's own history, two
151annotated tags, and a `fixtures` branch built for this step: a commit with a rename, a
152binary change, an addition and a deletion; a commit adding a 5,000-line file; and a commit
153adding 15 MB of text. Checked in the browser: both colour modes on the commit page, an
154800px viewport with no horizontal page overflow (long lines scroll inside their own
155container), the sticky file header, all three compare states, the two truncation states,
156a branch name containing a slash through the compare URL, and 404 for an anonymous
157visitor 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
160UTF-8.
161
162Screenshots are in the wave's shots directory, prefixed `commits-`.
plans/progress.md+27 −0View file
@@ -82,6 +82,33 @@ and `.zip` streamed from `git archive`; `/search?q=&rev=` over `git grep -F`.
8282 - `topcoat dev` in a worktree exited rather than restarting after some source edits and
8383 had to be relaunched by hand. Possibly two instances sharing a `target/`. Watch it.
8484
85+**Commit page and compare view (merged 2026-09-05).** `/commits/{sha}`, `/compare`
86+(form) and `/compare/{base}...{head}`, three-dot. Every sha in the log and the
87+latest-commit bar links to the commit page.
88+
89+- **The counts and the patch come from one `git` process**: `diff-tree --numstat -p`
90+ writes numstat *first*, so a patch too large to draw still lists every file with real
91+ `+a −b`. A second `--numstat` call would be paid on every page for the rare oversized
92+ one.
93+- **`run_capped` reads through a pipe and kills the child** at `max_bytes + 1`; the exit
94+ status is checked only when the cap was not hit, since a killed process says so.
95+- **`run_allowing` bends the "non-zero exit is a fault" rule for exactly two commands**
96+ whose codes git's manual promises as answers: `grep` (1 = matched nothing) and
97+ `merge-base` (1 = no common ancestor, 128 = broken). Both arrived in the same wave with
98+ their own copy; the merge kept one. A third caller needs the same scrutiny.
99+- **Compare is three-dot** (merge-base to head): two-dot undoes everything base gained
100+ since the branch left, a wall of deletions nobody made, and three-dot is what a pull
101+ request needs. `...` is unambiguous because `RefName` refuses `..`.
102+- **The patch parser tracks hunk line counts**, never matching `diff --git`/`+++`/`---`
103+ prefixes anywhere: a patch *of a patch* contains those as content.
104+- **Paths come from `---`/`+++`/`rename from`, not the `diff --git` header**, whose
105+ `a/<old> b/<new>` has no defined split when names contain spaces. C-quoted names are
106+ unquoted.
107+- **The compare form GETs to `/compare?base=&head=` and 307s to the path form**, so the
108+ shareable URL is always the path one and no JavaScript is needed.
109+- **The file header is `sticky`, which is why the diff panel is not `overflow-hidden`**:
110+ a hidden overflow makes an ancestor a scroll container and sticky sticks to nothing.
111+
85112 ### Milestone 0 — Skeleton · done
86113
87114 Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
plans/ui.md+13 −2View file
@@ -143,6 +143,17 @@ to say, pushing the code below the fold.
143143 the thing being looked for, the same rule. Every search state is a bordered panel with
144144 one sentence. A long matched line scrolls inside its own `<code>`.
145145
146+- **A commit page is a Commits-tab page**, and its Commits link points at the log at
147+ that commit's own id, not the revision in the URL: a branch name there would send
148+ someone to a different commit tomorrow. **A compare page is a Code-tab page**: it is
149+ about two revisions of the code, reached from the branches page and also directly at
150+ `/compare`.
151+- **Success and destructive appear as text in exactly three places**: the commit's
152+ `+a −b`, each file header's, and the truncated file list. Diff line tints are the same
153+ tokens at 12% (rows) and 20% (gutters) through `color-mix` in plain CSS. The primary
154+ colour is used only for links out of a dead end ("View the whole file", "Compare them
155+ the other way round").
156+
146157 ### Where the next features go
147158
148159 The slots matter as much as what fills them today. Written down so the next feature
@@ -151,9 +162,9 @@ lands in the frame rather than beside it:
151162 | Feature | Where it goes |
152163 |---|---|
153164 | Issues, Pull requests | a tab each, beside Code and Commits |
154| A commit page | the sha in the latest-commit bar and in the log |
165+| A commit page | done — the sha in the latest-commit bar and in the log |
155166 | Branches, Tags | done — the counts beside the switcher and in the sidebar |
156| Compare | reached from a branch row on the branches page |
167+| Compare | done — a branch row on the branches page, or `/compare` directly |
157168 | Blame | a toggle in the blob's own header, beside Raw |
158169 | Archive download | done — two small links under the clone URL |
159170 | Code search | done — the box on the right of the toolbar row |