| 1 | # Current |
| 2 | |
| 3 | > Keep this file short. One active step, one ordered backlog. Completed work moves to |
| 4 | > [progress.md](progress.md). If this file starts reading like a changelog, it has |
| 5 | > drifted — that's exactly what went wrong last time. |
| 6 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 7 | ## Active: Milestone 4a — Clone over HTTP |
2eb8681docs: put git next, plan the repo model24d | 8 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 9 | **Goal:** `git clone https://host/{handle}/repos/{name}.git` works against a public |
| 10 | repository, for anyone, with no credentials. The protocol is delegated to `git |
| 11 | http-backend` per [0001](decisions/0001-git-over-http-not-ssh.md). |
2eb8681docs: put git next, plan the repo model24d | 12 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 13 | **Out of scope:** personal access tokens, HTTP Basic, push, cloning a private repo — |
| 14 | all of that is 4b. Also out: browsing a tree in the UI (Milestone 5), and any repo |
| 15 | statistic the clone path could tempt us into computing. |
2eb8681docs: put git next, plan the repo model24d | 16 | |
| 17 | ### Steps |
| 18 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 19 | - [ ] Probe `git http-backend`'s actual contract — which CGI variables it reads, how it |
| 20 | reports failure, what it does with an unauthorised path. Findings to |
| 21 | `progress.md`; no application code in this step. |
47db238feat: serve the git protocol through http-backend, behind a port8d | 22 | - [x] Application: `GitProtocolServer` port — a CGI-shaped request/response pair, plus |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 23 | `GitOperation` (Read/Write) as the thing authorization is decided on |
47db238feat: serve the git protocol through http-backend, behind a port8d | 24 | - [x] Infrastructure: `GitHttpBackend` adapter, spawning through the existing `run_git` |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 25 | invoker; streams stdin in and stdout out, parsing CGI headers off the front |
| 26 | - [ ] Application: `serve_git` use case — resolves the repository, enforces visibility, |
| 27 | refuses writes outright, and only then delegates |
47db238feat: serve the git protocol through http-backend, behind a port8d | 28 | - [ ] Web: the three git routes under `/{handle}/repos/{name}.git/` |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 29 | - [ ] Verify with a real `git clone` of a repo with enough refs to trigger a gzipped |
| 30 | request body |
| 31 | |
| 32 | ### Done when |
| 33 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 34 | `git clone http://127.0.0.1:3000/{handle}/repos/{name}.git` produces a working |
| 35 | checkout of a public repository, with no credentials, and the cloned history matches |
| 36 | the origin. A private repository is not clonable by anyone yet — not even its owner. |
| 37 | `git push` is refused. |
2eb8681docs: put git next, plan the repo model24d | 38 | |
c6d74a8docs: record the repo model decisions24d | 39 | ### Settled |
2eb8681docs: put git next, plan the repo model24d | 40 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 41 | - **`git http-backend`, not direct `--stateless-rpc`.** Both put identical bytes on the |
| 42 | wire for a modern clone of a small repo; the difference is entirely in the tail, which |
| 43 | is what wide adoption means. Measured before choosing: a client cloning a repo with |
| 44 | 201 refs **gzip-compresses the POST body** (5KB here), on protocol v0 *and* v2. A |
| 45 | direct implementation must therefore inflate request bodies and forward |
| 46 | `Git-Protocol` itself, and gets neither the dumb-protocol fallback nor the header set. |
| 47 | The failure mode decided it — a direct implementation passes against a one-ref test |
| 48 | repo and breaks on the first real one. |
| 49 | - **The clone URL is `/{handle}/repos/{name}.git`**, matching the page at |
| 50 | `/{handle}/repos/{name}`. Scoped rather than root-level, per |
| 51 | [0003](decisions/0003-scoped-urls.md); the `.git` suffix separates protocol from page. |
| 52 | - **Only the three known endpoints are routed** — `info/refs`, `git-upload-pack`, |
| 53 | `git-receive-pack`. `http-backend` will otherwise serve dumb-protocol object files |
| 54 | under any path handed to it, which would be a read of a repository nothing |
| 55 | authorized. The router is the allowlist. |
| 56 | - **Authorization is decided before the subprocess is spawned**, from the service name |
| 57 | in the request, not from anything `http-backend` reports back. By the time git is |
| 58 | running it is too late to refuse. |
47db238feat: serve the git protocol through http-backend, behind a port8d | 59 | - **Bodies stream in both directions, rather than buffering.** A pack is arbitrarily |
| 60 | large, and buffering would bound a clone by RAM instead of by disk; |
| 61 | [0001](decisions/0001-git-over-http-not-ssh.md) named streaming as the thing that made |
| 62 | this transport viable in the first place. The port therefore carries |
| 63 | `Pin<Box<dyn AsyncRead + Send>>` in both directions. **No new crate**: this needs only |
| 64 | tokio's `io-util` feature, and the `bytes` / `http-body-util` / `tokio-util` the web |
| 65 | layer will use to bridge Topcoat's `Body` are already in `Cargo.lock` as transitive |
| 66 | dependencies, so nothing new enters the build. |
| 67 | - **`run_git` was split into `git_command`.** The old invoker buffers with `output()`, |
| 68 | which the protocol cannot use. Rather than a second recipe — the thing |
| 69 | [0006](decisions/0006-git-binary-behind-narrow-ports.md) exists to prevent — the |
| 70 | isolation moved into a shared builder both call sites start from. |
| 71 | - **`body_limit` turns out not to exist** in `topcoat-router` 0.5.0. Bodies are read by |
| 72 | the handler via `to_bytes(body, limit)` with a caller-chosen limit, so there is no |
| 73 | layer to raise — and the git routes take the body unbuffered anyway. The warning |
| 74 | carried from [0001](decisions/0001-git-over-http-not-ssh.md) is stale. |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 75 | - **Milestone 4 was split.** See [ROADMAP.md](ROADMAP.md#why-this-order). |
3954b45docs: record milestone 2 phase 125d | 76 | |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 77 | ### Open |
| 78 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 79 | - **What a private repository answers to an anonymous clone.** 4a has no credentials at |
| 80 | all, so 404 is the only honest answer and matches `view_repo`'s "absent, not |
| 81 | forbidden" rule. But git only sends credentials *after* a 401, so 4b will need a 401 |
| 82 | with `WWW-Authenticate` on exactly the case that 404s today — which leaks that the |
| 83 | repository exists. Gitea and GitHub both accept that leak. Decide it in 4b, with the |
| 84 | tension recorded rather than rediscovered. |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 85 | |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 86 | ### Watch for |
d7b99d9docs: record milestone 0 progress and routing findings1mo | 87 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 88 | - **CGI header parsing sits in front of a stream.** `http-backend` writes headers, a |
| 89 | blank line, then the body. Reading the headers must not buffer the body — that is the |
| 90 | whole reason this transport was judged viable on `Body::into_data_stream`. |
47db238feat: serve the git protocol through http-backend, behind a port8d | 91 | - **A client that disappears mid-request leaves the body-copy task waiting.** The copy |
| 92 | into git's stdin runs in its own task; nothing cancels it if the connection drops. |
| 93 | Bounded by the backend exiting and closing the pipe, but not by anything deliberate. |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 94 | - **A subprocess per request**, unlike Milestone 3's once-per-creation. Fork/exec cost |
| 95 | now sits on a hot path; measure before assuming it is fine. |
| 96 | - **`http-backend` reports failure through CGI status lines**, not exit codes alone. A |
| 97 | non-zero exit and a `404 Not Found` on stdout mean different things. |
| 98 | - **The advertisement must not be cached.** `Cache-Control: no-cache` on `info/refs`, or |
| 99 | clients fetch a stale ref list and fail to find commits that exist. |
076dbc9docs: close milestone 1, open milestone 21mo | 100 | |
f0444b7docs: plan milestone 2 in two phases1mo | 101 | ### Carried over — small, unblocked |
076dbc9docs: close milestone 1, open milestone 21mo | 102 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 103 | - **An orphaned repo directory is possible** if the process dies between the record |
| 104 | write and the filesystem write, and it then blocks re-creating that name. The durable |
| 105 | fix is a reconciliation sweep on boot |
| 106 | ([architecture.md](architecture.md#db-plus-filesystem-writes)); clearing one is a |
| 107 | manual `rm` today, since repo deletion does not exist. |
| 108 | - **The duplicate-name check races.** The loser is caught by `init_bare` or the unique |
| 109 | constraint, but surfaces as an opaque storage error rather than "name taken". |
| 110 | - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the |
| 111 | data directory ever moves to Linux. |
2eb8681docs: put git next, plan the repo model24d | 112 | - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back |
| 113 | today. Topcoat's `font-fontsource` feature handles it. |
| 114 | - **Light mode is untested.** The palette defines it; nobody has looked at it. |
f0444b7docs: plan milestone 2 in two phases1mo | 115 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
076dbc9docs: close milestone 1, open milestone 21mo | 116 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 117 | enforced on read, so this is tidiness, not a hole. |
2eb8681docs: put git next, plan the repo model24d | 118 | - **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable |
| 119 | rather than hypothetical. |
| 120 | |
| 121 | ## Backlog |
| 122 | |
| 123 | Ordered. Pull from the top. |
| 124 | |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 125 | 1. **Milestone 4b — Push and tokens.** Personal access tokens over HTTP Basic, `git |
| 126 | push`, private clone. Open decisions when it starts: how tokens are hashed (session |
| 127 | token hashing already exists to copy), whether tokens carry scopes, and the 401-vs-404 |
| 128 | tension above. |
02eb2e4feat: GitStorage port and DiskGitStorage24d | 129 | 2. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value |
| 130 | objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port |
| 131 | returning `String`s is an anaemic pass-through that pushes validation into the page. |
| 132 | Also the point to measure fork/exec cost per page view, and to reconsider `gix` for |
| 133 | the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)). |
2eb8681docs: put git next, plan the repo model24d | 134 | 3. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open |
| 135 | whether writing or projects/showcases is the better first portfolio feature. |
| 136 | |
| 137 | ## Open questions |
| 138 | |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 139 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 140 | by its own authors). Expect churn that isn't feature work. |
| 141 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 142 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 143 | |
| 144 | ## Routing findings (Milestone 0) |
| 145 | |
| 146 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 147 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 148 | stable is now 1.97.1. |
| 149 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 150 | so pages can live in any module. Layering is our choice, not the framework's. |
| 151 | - `module_router!` derives each URL from the module tree rather than a path string. |
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo | 152 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 153 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 154 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 155 | `module_router!` handles that before committing to it. |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 156 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 157 | injected as handler arguments. Parses are memoized per request. |
| 158 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 159 | `NotFoundError` to render a branded 404. |
| 160 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 161 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 162 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
ca76e1bdocs: fix milestone cross-references after the reorder24d | 163 | stream both directions without buffering. This is what makes Milestone 4 viable. |