steid

@jamesgill /

steid/plans/handover-commits.md
9.7 KBCode·Blame·Raw
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-`.