| 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 | |
a814db5feat: push and clone private repositories with a token8d | 7 | ## Active: Milestone 5 — Repo browsing |
| 8 | |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 9 | **Goal:** a repository's contents are readable on the web — the file tree at a ref, a |
| 10 | single file's contents, and the commit log. A visitor can look at code without cloning |
| 11 | it, which is the first time the profile behaves like a portfolio rather than a list of |
| 12 | names. |
a814db5feat: push and clone private repositories with a token8d | 13 | |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 14 | **Out of scope:** editing files, diffs, blame, syntax highlighting, rendering a README as |
| 15 | markdown (that wants the markdown pipeline Milestone 6 brings), search, and a |
| 16 | last-commit-per-file column — see Open, where that one is a decision rather than an |
| 17 | omission. |
a814db5feat: push and clone private repositories with a token8d | 18 | |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 19 | ### Steps |
| 20 | |
| 21 | - [ ] Domain: `ObjectId`, `RefName`, `TreeEntry` — value objects **before** any adapter, |
| 22 | per [0006](decisions/0006-git-binary-behind-narrow-ports.md) |
| 23 | - [ ] Application: `GitQuery` port — `resolve_ref`, `list_tree`, `read_blob`, `log` |
| 24 | - [ ] Infrastructure: adapter over the `git` binary, through the existing `git_command` |
| 25 | - [ ] Application: `browse_repo` read model, reusing `view_repo`'s visibility rule |
| 26 | - [ ] Web: the tree page, and the repository page showing its default branch |
| 27 | - [ ] Web: the blob page, and the commit log |
| 28 | - [ ] Verify in a browser against a real repository, including an empty one |
| 29 | |
| 30 | ### Done when |
| 31 | |
| 32 | A visitor can open a public repository from a profile, see its files at the default |
| 33 | branch, click into a directory and then a file and read its contents, and open the commit |
| 34 | log. A private repository shows none of this to someone who may not see it. An empty |
| 35 | repository says so rather than erroring. |
| 36 | |
| 37 | ### Settled |
| 38 | |
| 39 | - **Value objects come first.** A query port returning `String`s is an anaemic |
| 40 | pass-through that pushes validation into the page, which |
a814db5feat: push and clone private repositories with a token8d | 41 | [0006](decisions/0006-git-binary-behind-narrow-ports.md) rejected in advance. |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 42 | |
509ba47docs: settle the Milestone 5 read path on the git binary8d | 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`. |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 54 | - **Fork/exec is ~11–12ms per call, and it is the process, not the query.** Measured on |
| 55 | a 201-commit repository, averaged over 50 runs each: `rev-parse` 11.2ms, `ls-tree` |
| 56 | 11.7ms, `cat-file` 11.6ms, `log -20` 11.8ms, `for-each-ref` 14.4ms. The work is |
| 57 | free; starting git is not — which matches the ~13ms `git init --bare` measured in |
| 58 | Milestone 3. **A three-call page therefore costs ~35ms of pure overhead.** That is the |
| 59 | number the Open decision below turns on, and it is why a per-file last-commit column is |
| 60 | a decision and not a detail: at one call per entry, a twenty-file directory is ~230ms |
| 61 | before any real work. |
8608574feat: personal access tokens, as a domain type8d | 62 | |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 63 | ### Open |
| 64 | |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 65 | - **The ref-versus-path ambiguity in the URL.** `/{handle}/repos/{name}/tree/{ref}/{path}` |
| 66 | is unparseable in general, because a ref may contain slashes: `tree/feature/x/README` |
| 67 | splits two ways. GitHub resolves it by trying candidate splits against the real ref |
| 68 | list; GitLab inserts a `/-/` separator. A third option is a single-segment ref with the |
| 69 | path after it, refusing refs with slashes. This is a URL shape, so it is expensive to |
509ba47docs: settle the Milestone 5 read path on the git binary8d | 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. |
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d | 76 | |
| 77 | ### Watch for |
| 78 | |
| 79 | - **An empty repository has no `HEAD`.** Steid creates repositories empty, and |
| 80 | Milestone 4 made it easy to have one that was never pushed to. `rev-parse HEAD` fails |
| 81 | rather than returning nothing, and the repository page must say "nothing here yet" |
| 82 | instead of erroring. |
| 83 | - **A blob is not necessarily text.** Binary files, invalid UTF-8, and very large files |
| 84 | all reach the same page. Decide what each does rather than discovering it. |
| 85 | - **Paths in URLs reach the filesystem indirectly**, via git rather than directly, but a |
| 86 | path is still user input arriving at a subprocess argument. `--` before path arguments, |
| 87 | as `init_bare` already does. |
| 88 | - **The commit log is unbounded.** A repository with 50,000 commits needs a limit before |
| 89 | the page renders one. |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 90 | |
e0856ebfeat: clone a public repository over HTTP8d | 91 | ### Carried over — small, unblocked |
d7b99d9docs: record milestone 0 progress and routing findings1mo | 92 | |
47db238feat: serve the git protocol through http-backend, behind a port8d | 93 | - **A client that disappears mid-request leaves the body-copy task waiting.** The copy |
e0856ebfeat: clone a public repository over HTTP8d | 94 | into git's stdin runs in its own task and nothing cancels it if the connection drops. |
47db238feat: serve the git protocol through http-backend, behind a port8d | 95 | Bounded by the backend exiting and closing the pipe, but not by anything deliberate. |
a814db5feat: push and clone private repositories with a token8d | 96 | - **`REMOTE_USER` is not set on the backend**, so a push is recorded in the repository's |
| 97 | reflog without naming who made it. Steid knows the actor by then; it simply is not |
| 98 | passed through. Small, and worth doing before anything reads reflogs. |
| 99 | - **No rate limiting on token authentication.** A token is 256 bits so guessing is not |
| 100 | the worry; unbounded hashing on an open endpoint is. |
| 101 | - **Tokens have no expiry and no last-used timestamp.** Both deliberate omissions for |
| 102 | now — see [0007](decisions/0007-tokens-over-http-basic.md) — but a token list with no |
| 103 | "last used" makes it hard to know which are safe to revoke. |
e0856ebfeat: clone a public repository over HTTP8d | 104 | - **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a |
| 105 | hot path and has not been measured. Milestone 5 is where that bill comes due. |
| 106 | - **Streaming is by construction, not by measurement.** The response body is never |
| 107 | collected, but no clone large enough to prove it has been run. |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 108 | - **An orphaned repo directory is possible** if the process dies between the record |
| 109 | write and the filesystem write, and it then blocks re-creating that name. The durable |
| 110 | fix is a reconciliation sweep on boot |
| 111 | ([architecture.md](architecture.md#db-plus-filesystem-writes)); clearing one is a |
| 112 | manual `rm` today, since repo deletion does not exist. |
| 113 | - **The duplicate-name check races.** The loser is caught by `init_bare` or the unique |
| 114 | constraint, but surfaces as an opaque storage error rather than "name taken". |
| 115 | - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the |
| 116 | data directory ever moves to Linux. |
2eb8681docs: put git next, plan the repo model24d | 117 | - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back |
| 118 | today. Topcoat's `font-fontsource` feature handles it. |
| 119 | - **Light mode is untested.** The palette defines it; nobody has looked at it. |
f0444b7docs: plan milestone 2 in two phases1mo | 120 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
076dbc9docs: close milestone 1, open milestone 21mo | 121 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 122 | enforced on read, so this is tidiness, not a hole. |
2eb8681docs: put git next, plan the repo model24d | 123 | - **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable |
| 124 | rather than hypothetical. |
| 125 | |
| 126 | ## Backlog |
| 127 | |
| 128 | Ordered. Pull from the top. |
| 129 | |
a814db5feat: push and clone private repositories with a token8d | 130 | 1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open |
2eb8681docs: put git next, plan the repo model24d | 131 | whether writing or projects/showcases is the better first portfolio feature. |
| 132 | |
| 133 | ## Open questions |
| 134 | |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 135 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 136 | by its own authors). Expect churn that isn't feature work. |
| 137 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 138 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 139 | |
| 140 | ## Routing findings (Milestone 0) |
| 141 | |
| 142 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 143 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 144 | stable is now 1.97.1. |
| 145 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 146 | so pages can live in any module. Layering is our choice, not the framework's. |
| 147 | - `module_router!` derives each URL from the module tree rather than a path string. |
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo | 148 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 149 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 150 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 151 | `module_router!` handles that before committing to it. |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 152 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 153 | injected as handler arguments. Parses are memoized per request. |
| 154 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 155 | `NotFoundError` to render a branded 404. |
| 156 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 157 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 158 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
ca76e1bdocs: fix milestone cross-references after the reorder24d | 159 | stream both directions without buffering. This is what makes Milestone 4 viable. |