steid

@jamesgill /

docs: fold the archive and search handover into plans

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

4 files changed+48 −113

plans/current.md+12 −0View file
@@ -157,6 +157,18 @@ instance, and Steid's own source is pushed to it and browsable there.
157157 `handle` and `name` so the sidebar counts can link. Watch for the next page that wants
158158 `timed_out` and move it to `web::context` then.
159159
160+- **`git grep` output is collected whole before it is capped.** A one-letter query on a
161+ big repository allocates all of git's output to keep 200 hits. Bounded by the 20 s
162+ timeout, not by anything deliberate.
163+- **The archive endpoint has no rate limiting** and is outside the read timeout, on
164+ purpose (a large repository legitimately takes longer to pack). It is the most
165+ expensive anonymous request in Steid: a full pack per hit. First thing to look at under
166+ load. A late `git archive` failure is a truncated download, logged like the protocol
167+ server's.
168+- **Search has no revision switcher on the results page**, no paging, `trim()`s the
169+ query, cuts matched lines at 500 characters (which can remove the match on a minified
170+ line), and ignores `GrepHit::column` when marking.
171+
160172 ### Carried over — small, unblocked
161173
162174 - **A client that disappears mid-request leaves the body-copy task waiting.** The copy
plans/handover-archive.md+0 −111
@@ -1,111 +0,0 @@
1# Handover — `archive` (archive download + code search)
2
3Branch `feat/archive-search`. Two commits, both compiling, tests green at each.
4Fold this into `progress.md` (decisions), `current.md` (holes) and `ui.md` (UX) at
5merge time.
6
7## What shipped
8
9- `GET /{handle}/repos/{name}/archive/{rev}.tar.gz` and `.zip` — streamed `git archive`.
10- `GET /{handle}/repos/{name}/search?q=&rev=` — fixed-string `git grep` over one revision.
11- Entry points: two links under the clone URL in the About sidebar; a search box on the
12 right of the landing page's toolbar row. Both are the slots `ui.md`'s table reserved.
13- The blob's line-number cells now carry `id="L{n}"`.
14
15## progress.md material — decisions worth not rediscovering
16
17- **`GitArchive` is a fourth narrow port, not a `GitQuery` method.** `GitQuery`'s whole
18 contract is *capped bytes, collected*; an archive is as large as the repository. It
19 therefore lives in `infrastructure/git.rs` beside the protocol server, whose shape it
20 shares, and reuses `web::git::GitBody` verbatim — one subprocess's stdout becoming a
21 response body is now written once and used twice.
22- **The archive use case is two steps and that is what makes the `ETag` cheap.**
23 `archive_repo` authorizes and resolves, handing back an `ArchiveTarget` that nothing
24 else can construct; `open_archive` consumes it and runs git. A conditional request is
25 answered from step one, so a repeat download of a tag costs one `cat-file`, not a
26 pack. The `ETag` is the resolved commit, so `main` correctly re-downloads after a push
27 and a tag never does.
28- **`git grep` is the one command in `git_query.rs` whose exit status is an answer.** It
29 exits 1 for "matched nothing". `run_within_allowing` takes an allowed-exit list and
30 `NO_MATCHES` names the exception at the call site, so the module's rule — a non-zero
31 exit is always a fault — is broken deliberately in one visible place rather than
32 softened everywhere.
33- **`git grep -z` is not optional.** Without it the field separator is a colon, and a
34 path may contain colons, so `src/a:b.rs:12:3:text` has no unambiguous reading. With
35 `-z` the record is `<commit>:<path> NUL <line> NUL <column> NUL <text> LF`, and the
36 parser walks NULs rather than splitting lines — which is also what survives a newline
37 inside a *path*.
38- **`RepoPath` refuses a colon, so a file named `a:b.rs` is unsearchable as well as
39 unbrowsable.** The parser drops those hits rather than linking to a page that must
40 404. Pre-existing constraint, newly visible; there is a test pinning it.
41- **A timeout is a page, not a 500.** `search_repo` catches `GitQueryError::is_timeout`
42 and returns `Searched::TimedOut`, which is the first real consumer of the timeout kind
43 the previous commit added.
44- **Archive and search both 404 a private repository for a stranger**, matching every
45 other read page. See the open question below — the brief asked for 401.
46- **A subtlety in the dev loop, not in the code:** `topcoat dev` in this worktree exited
47 rather than restarting after two of the three source edits made while the server was
48 running, and had to be relaunched by hand. Nothing in the log explained it. Worth
49 watching; may be two `topcoat dev` instances sharing a `target/`.
50
51## current.md material — holes opened, shortcuts taken
52
53- **`git grep` output is collected whole before it is capped.** `run` reads the
54 subprocess's stdout into memory, so a one-letter query against a large repository
55 allocates all of git's output even though only 200 hits are kept. `git grep` has no
56 portable global maximum; `-m` is per file. Bounded in practice by the 20-second
57 timeout, not by anything deliberate.
58- **The search box searches the revision being browsed but the results page does not
59 offer a revision switcher.** Changing revision from the results means editing the URL.
60 The switcher lives in `browse.rs` and is private; exposing it is a one-word change
61 when someone wants it.
62- **A query is `trim()`ed**, so a search for a trailing space is a search without it.
63 Conventional, and wrong for anyone hunting trailing whitespace.
64- **Matched lines are cut at 500 characters**, and the cut can remove the match itself
65 on a minified line. The alternative was letting one bundle make the page megabytes.
66- **Highlighting re-scans the line for every occurrence** and ignores the column git
67 reported, so `GrepHit::column` is carried but unused by the page.
68- **No paging on search**, the same v1 decision as the log's 50.
69- **The archive endpoint has no rate limiting.** It is the most expensive anonymous
70 request in Steid — a full pack of the repository per hit, with a 20-second-plus
71 ceiling nothing enforces because `git archive` does not go through `GitQuery::run`.
72 Cheap to abuse, and the first thing to look at if an instance is ever put under load.
73- **`git archive` is not covered by the read timeout.** Deliberately: a legitimately
74 large repository takes longer than twenty seconds to pack, and killing that is worse
75 than the request being slow. It is bounded only by `kill_on_drop` when the client
76 goes away.
77- **A late `git archive` failure is a truncated download**, not an error — the status is
78 known only after headers have been sent. Logged, same as the protocol server's.
79
80## ui.md material
81
82- **Archive download** and **Code search** in "Where the next features go" are both
83 filled in; the table entries can be marked done.
84- **The download links are for the revision being viewed**, not always the default
85 branch — someone reading a tag wants that tag's tarball. An empty repository shows no
86 links, because there is nothing to pack.
87- **The search box is the width of the About sidebar** on `sm` and up and full width
88 below it, so the two columns of the landing page line up. On the search page itself it
89 is full width.
90- **`<mark>` is `bg-primary/25`**, which is the only place other than the active tab and
91 the latest-commit dot where the primary colour appears on a repository page. It marks
92 the thing being looked for, which is the same rule.
93- **Every search state is a bordered panel with one sentence**: no commits, no query, no
94 matches, query too long, timed out. They read as the same object because they are.
95- **A long matched line scrolls inside its own `<code>`**, verified at 800px — the page
96 itself does not scroll horizontally.
97
98## Verified
99
100- `cargo test` — 503 pass. `cargo clippy --all-targets` — zero warnings. `cargo fmt`.
101- Against a real instance on :3103, this repository pushed into it with 7 branches and
102 two annotated tags: both archives downloaded and **extracted** (`tar tzf`, `unzip`)
103 with the `steid-main/` and `steid-v0.2.0/` prefix directories; `If-None-Match` on the
104 returned `ETag` answered 304; unknown revision, unknown extension, and a private
105 repository as an anonymous visitor all answered 404.
106- Search verified live for results, no matches, no query, truncation at 200, an
107 over-long query, a private repository (404) and an unknown revision (404).
108- Both colour modes looked at by flipping the layout's `class="dark"`, restored after.
109- **Not verified:** the timeout state in a browser — it is unit-tested through the
110 in-memory port, but no repository here is slow enough to trigger a real 20-second
111 grep. The `Searched::TimedOut` page has never been rendered.
plans/progress.md+26 −0View file
@@ -56,6 +56,32 @@ TOML, Dockerfile, TypeScript — at +0.59 MiB on the binary. Decisions worth kee
5656 - **`refs.rs::timed_out` is the first web-layer place that renders a timeout as a state**
5757 rather than a 500. When a second page wants it, move it to `web::context`.
5858
59+**Archive download and code search (merged 2026-09-05).** `/archive/{rev}.tar.gz`
60+and `.zip` streamed from `git archive`; `/search?q=&rev=` over `git grep -F`.
61+
62+- **`GitArchive` is a fourth narrow port, not a `GitQuery` method.** `GitQuery`'s contract
63+ is capped bytes, collected; an archive is as large as the repository. It lives in
64+ `infrastructure/git.rs` beside the protocol server and reuses `web::git::GitBody`, so
65+ "one subprocess's stdout becomes a response body" is written once and used twice.
66+- **The archive use case is two steps, which is what makes the `ETag` cheap.**
67+ `archive_repo` authorizes and resolves into an `ArchiveTarget` nothing else can
68+ construct; `open_archive` runs git. A conditional request is answered from step one,
69+ so re-downloading a tag costs one `cat-file`. The `ETag` is the resolved commit.
70+- **`git grep` is the one command whose exit status is an answer** (1 = no matches).
71+ `run_within_allowing` takes an allowed-exit list and `NO_MATCHES` names the exception
72+ at the call site, so the module's rule stays visible where it is broken.
73+- **`git grep -z` is not optional**: a path may contain colons, so the colon-separated
74+ form has no unambiguous reading. The parser walks NULs.
75+- **`RepoPath` refuses a colon**, so `a:b.rs` is unsearchable as well as unbrowsable; the
76+ parser drops those hits rather than link to a 404. Pinned by a test.
77+- **A search timeout is a page state** (`Searched::TimedOut`), the first real consumer of
78+ `GitQueryError::is_timeout`.
79+- **Archive and search 404 a private repository for a stranger**, matching every other
80+ read page rather than 0007's uniform 401, which exists so a git client can offer a
81+ credential; a browser link gets no benefit from it.
82+- `topcoat dev` in a worktree exited rather than restarting after some source edits and
83+ had to be relaunched by hand. Possibly two instances sharing a `target/`. Watch it.
84+
5985 ### Milestone 0 — Skeleton · done
6086
6187 Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
plans/ui.md+10 −2View file
@@ -135,6 +135,14 @@ to say, pushing the code below the fold.
135135 `--syntax-*` block in `styles.css`, so it follows the palette. The over-cap notice is a
136136 row of the blob panel sharing the header's hairline, a page state rather than a banner.
137137
138+- **Download links are for the revision being viewed**, under the clone URL; someone
139+ reading a tag wants that tag's tarball. An empty repository shows none.
140+- **The search box is the width of the About sidebar** on `sm` and up, so the two columns
141+ line up; full width below and on the results page. `<mark>` is `bg-primary/25`, the only
142+ other primary on the page besides the active tab and the latest-commit dot: it marks
143+ the thing being looked for, the same rule. Every search state is a bordered panel with
144+ one sentence. A long matched line scrolls inside its own `<code>`.
145+
138146 ### Where the next features go
139147
140148 The slots matter as much as what fills them today. Written down so the next feature
@@ -147,8 +155,8 @@ lands in the frame rather than beside it:
147155 | Branches, Tags | done — the counts beside the switcher and in the sidebar |
148156 | Compare | reached from a branch row on the branches page |
149157 | Blame | a toggle in the blob's own header, beside Raw |
150| Archive download | two small links under the clone URL |
151| Code search | a box on the right of the toolbar row, which is empty for it |
158+| Archive download | done — two small links under the clone URL |
159+| Code search | done — the box on the right of the toolbar row |
152160 | Per-file last commit | a column in the listing, once `cat-file --batch` is kept alive |
153161
154162 **The cost is known and accepted.** The landing page makes **15 `git` processes** for a