steid

@jamesgill /

docs: settle the Milestone 5 read path on the git binary

0006 deferred this call to Milestone 5 and asked for a measurement rather than an
intuition, so this answers it with one: ~11-12ms per git invocation regardless of
what is asked, because the cost is starting the process. A three-call page is
~35ms of overhead.

gix is rejected, but not for the reason assumed. Its read path is comprehensively
capable — checked against gitoxide's crate status, not recalled. The obstacle is
that its server-side protocol support is in-process plumbing only, so with 0001
committing to http-backend the binary is permanent, and adopting gix would add a
second implementation rather than replace one. Two libraries reading the same
bytes forever, to save 35ms a page for one user.

Forking gitoxide to write the server side is rejected separately: it only pays
off if the binary can be deleted, which needs receive-pack — fsck, quarantine,
atomic ref updates, hooks — against untrusted packs from the internet. That work
belongs upstream, not on the critical path of a project whose own roadmap names
protocol work as what killed its predecessors.

The upgrade path is the point, and it has one prerequisite that must land now:
GitQuery is a shared handle in app context, not built per request like the SQLite
adapters. A batch adapter owns live subprocesses, and per-request construction
would spawn and kill one per request — the fork cost back, plus a pool that never
pools. The port gives the seam; it does not give the lifetime.

Also closes the browsing-versus-writing ordering: hosting steid on steid makes
browsing the feature that matters, and records the no-last-commit-column
consequence rather than leaving it to be discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent338102bBrowse files509ba47ac40d34b658972771fdd0bb59fdefee5f

2 files changed+89 −16

plans/current.md+17 −16View file
@@ -40,6 +40,17 @@ repository says so rather than erroring.
4040 pass-through that pushes validation into the page, which
4141 [0006](decisions/0006-git-binary-behind-narrow-ports.md) rejected in advance.
4242
43+- **The read path stays on the `git` binary, one process per query**, with
44+ `cat-file --batch` as the named upgrade and `gix` closed off — reasoning, the
45+ measurement, and the reopening conditions in
46+ [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
47+- **`GitQuery` is a shared handle in app context**, not constructed per request the way
48+ the SQLite adapters are. Stage 1 does not need the sharing; stage 2 owns live
49+ subprocesses and cannot work without it. The port gives us the seam, not the lifetime,
50+ and getting the lifetime wrong now means touching every page later.
51+- **No per-file last-commit column in v1.** The direct consequence of the above: at one
52+ fork per entry a twenty-file directory is ~230ms. If it is missed, that is the trigger
53+ to climb to stage 2 rather than to reopen `gix`.
4354 - **Fork/exec is ~11–12ms per call, and it is the process, not the query.** Measured on
4455 a 201-commit repository, averaged over 50 runs each: `rev-parse` 11.2ms, `ls-tree`
4556 11.7ms, `cat-file` 11.6ms, `log -20` 11.8ms, `for-each-ref` 14.4ms. The work is
@@ -51,27 +62,17 @@ repository says so rather than erroring.
5162
5263 ### Open
5364
54- **What to do about the fork/exec cost.** Three ways, and the measurement above is the
55 input:
56 1. **Keep the binary, design pages to make few calls.** Nothing new to learn, ~35ms a
57 page, and it rules out any per-entry column. Simplest, and honest for a
58 personal-scale forge.
59 2. **`git cat-file --batch`-style long-lived processes.** One process answering many
60 queries over stdin, so the per-call cost mostly disappears without a second
61 implementation of git. Costs a process lifecycle and a protocol to parse.
62 3. **`gix` for the read path**, which 0006 explicitly left open to revisit here.
63 Microseconds instead of milliseconds, and the place `gix` is strongest — but it
64 means two implementations holding assumptions about the same on-disk format, which
65 is the exact thing 0006 rejected it for.
6665 - **The ref-versus-path ambiguity in the URL.** `/{handle}/repos/{name}/tree/{ref}/{path}`
6766 is unparseable in general, because a ref may contain slashes: `tree/feature/x/README`
6867 splits two ways. GitHub resolves it by trying candidate splits against the real ref
6968 list; GitLab inserts a `/-/` separator. A third option is a single-segment ref with the
7069 path after it, refusing refs with slashes. This is a URL shape, so it is expensive to
71 change later.
72- **Whether writing (Milestone 6) should come first.** The ladder puts browsing next, but
73 the vision is portfolio-first and writing is the more distinctive feature, where
74 browsing is the more expected one. Worth deciding before starting rather than after.
70+ change later. **Recommendation: the separator.** Candidate splits cost a ref lookup —
71+ another fork on every page — which the decision above makes more expensive, and a
72+ separator is unambiguous by construction rather than by lookup.
73+- **How a blob page handles what is not source code.** Binary files, invalid UTF-8, and
74+ very large files all arrive at the same page. Deciding beats discovering.
75+- **How far back the commit log goes** before it needs paging.
7576
7677 ### Watch for
7778
plans/decisions/0006-git-binary-behind-narrow-ports.md+72 −0View file
@@ -107,3 +107,75 @@ What survives unchanged is the part that mattered: several narrow ports rather t
107107 git service, and one infrastructure-side invoker owning how git is actually run.
108108 Authorization still happens in the use case, before the subprocess exists — the CGI
109109 shape moves *how* git is called, not *who decides* it may be.
110+
111+## Amendment — 2026-08-29, the Milestone 5 read path
112+
113+0006 deferred one question to this point: whether `gix` should take over the read path,
114+"where browsing is read-only, hot, and the place `gix` is strongest". Answered here, with
115+the measurement it asked for.
116+
117+**The read path stays on the `git` binary, one process per query, for now.**
118+
119+### What was measured
120+
121+On a 201-commit repository, 50 runs averaged per command: `rev-parse` 11.2ms, `ls-tree`
122+11.7ms, `cat-file` 11.6ms, `log -20` 11.8ms, `for-each-ref` 14.4ms. **The cost is
123+starting the process, not answering the question** — every command lands in the same
124+band regardless of the work it does, matching the ~13ms `git init --bare` from Milestone
125+3. A three-call tree page is therefore ~35ms of pure overhead.
126+
127+### Why not `gix`
128+
129+Checked against gitoxide's own crate status rather than recalled. The read path is
130+comprehensively covered — `gix-ref`, `gix-odb`, `gix-pack`, `gix-object`, `gix-traverse`
131+and `gix-revwalk` all do what browsing needs. Capability is not the obstacle.
132+
133+**Server-side protocol support is.** `gix-protocol` offers `upload-pack` /
134+`receive-pack` plumbing *for in-process transports* only — not a network server. Since
135+[0001](0001-git-over-http-not-ssh.md) commits to `git http-backend`, the binary is a
136+permanent runtime dependency, so adopting `gix` would not replace an implementation. It
137+would **add** one: two libraries reading the same bytes on disk, forever, needing to
138+agree. That is the cost this ADR priced originally, now confirmed rather than assumed —
139+and the price of avoiding it is 35ms a page for one user.
140+
141+### Why not fork gitoxide and write the server side
142+
143+Considered and rejected. A fork only pays off if it lets Steid delete the binary, and
144+that requires server-side `receive-pack`: pack verification and fsck, quarantine, atomic
145+ref updates, hooks, report-status — executed against untrusted packs from the internet,
146+a surface with a real CVE history. Months of work, no user-visible capability, and
147+[ROADMAP.md](../ROADMAP.md#why-this-order) names disappearing into protocol work as what
148+ended both previous attempts. If the work is wanted for its own sake it belongs upstream
149+in gitoxide, whose stated goal already includes server support, on its own timeline —
150+not on Steid's critical path.
151+
152+### The upgrade path, which is the point
153+
154+Staged, and each stage keeps `GitQuery` unchanged:
155+
156+1. **One-shot commands.** ~35ms a page. Where Milestone 5 starts.
157+2. **A `cat-file --batch` process held open per repository.** Deletes the fork cost for
158+ object reads; what Gitaly and Gitea both do. Projected at ~1–3ms a page — projected,
159+ not measured, and to be benchmarked before it is claimed.
160+3. **`gix-object` decoding bytes fetched by stage 2**, if tree and commit parsing ever
161+ need to happen in-process. Worth distinguishing from adopting `gix`: a decoder for a
162+ documented format is not a second implementation of repository *access* — no ref
163+ resolution, no pack access, no protocol. Full `gix` and the fork stay rejected;
164+ neither is on this ladder.
165+
166+**One thing must be got right now for stage 2 to stay cheap.** A batch adapter owns live
167+subprocesses, so it has to be a shared handle in app context rather than constructed per
168+request as the SQLite adapters are. Built per request, the pool would spawn and kill a
169+process per request — the fork cost back, plus a pool that never pools. The port gives us
170+the seam; it does not give us the lifetime, and the lifetime is a composition-root
171+decision that has to be made before the first adapter is wired.
172+
173+### Reopening conditions
174+
175+Evidence, not intuition:
176+
177+- A per-file last-commit column becomes a must-have — ~230ms for a twenty-file directory
178+ at one fork per entry. Go to stage 2 first, not to `gix`.
179+- Browsing takes real public traffic, making the read path hot rather than occasional.
180+- gitoxide ships usable server-side `upload-pack` and `receive-pack`, at which point it
181+ stops being additive and becomes a candidate to replace the binary outright.