| 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 | |
| 7 | ## Active: Milestone 3 — Repo model |
| 8 | |
| 9 | **Goal:** repositories exist as records and as bare git repos on disk, and they appear |
| 10 | on the profile. No git protocol yet — that is milestone 4. This milestone fills the |
| 11 | Repositories section and gets the storage layout right before anything serves it. |
| 12 | |
| 13 | **Out of scope:** clone, push, browsing a tree, README rendering, forks, stars. |
| 14 | Deleting a repo — worth having, but it makes the filesystem/database consistency |
| 15 | problem twice as interesting, so not in the first pass. |
| 16 | |
| 17 | ### Steps |
| 18 | |
| 19 | - [x] Domain: `RepoId`, `RepoName`, `Visibility` (Public/Private), `Repository` |
| 20 | - [x] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`, |
| 21 | `list_by_org`, `save` |
| 22 | - [x] Infrastructure: in-memory + SQLite implementations, migration |
| 23 | - [x] Application: `GitStorage` port — `init_bare`, `remove`, `repo_path` |
| 24 | - [x] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare` |
| 25 | - [x] Application: `create_repo` use case — owner only, validates, creates record and |
| 26 | bare repo |
| 27 | - [x] Application: `view_repo` read model — visibility-aware |
| 28 | - [x] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page |
| 29 | - [x] Application: `list_repos` + the profile's Repositories section listing what the |
| 30 | viewer may see |
| 31 | - [x] `/api/users/{handle}/repos` |
| 32 | |
| 33 | ### Done when |
| 34 | |
| 35 | The owner creates a repo through the UI, a bare repo appears at |
| 36 | `{data_dir}/{handle}/{name}.git`, and it is listed on the profile. A private repo is |
| 37 | invisible to a signed-out visitor. `git clone` does **not** work yet — that is |
| 38 | milestone 4. |
| 39 | |
| 40 | ### Settled |
| 41 | |
| 42 | - **`list_repos` returns `Option<Vec<_>>`.** `None` is an unknown handle, |
| 43 | `Some(vec![])` a handle whose repositories the viewer cannot see. `/api` needs that |
| 44 | difference to answer 404 rather than `[]` for a user who does not exist. |
| 45 | - **`RepoSummary` is separate from `RepoView`.** The owning handle and |
| 46 | `viewer_is_owner` are constant across a listing and already known to the page, so a |
| 47 | listing type carries neither. |
| 48 | - **`list_repos` was split from `view_repo`** and moved to the listing step. `view_repo` |
| 49 | had a consumer immediately; `list_repos` would have been a third read model with no |
| 50 | caller, which is what the previous three steps already were. |
| 51 | - **An invisible private repo is `None`, not `Forbidden`.** `view_repo` answers the same |
| 52 | way for "does not exist" and "not allowed to see", and the page 404s identically. A |
| 53 | 403 would confirm the repository exists and leak its name. |
| 54 | - **Private repos are visible to any member**, not only the owner — seeing is weaker |
| 55 | than changing, matching attempt #2's clone rule. |
| 56 | |
| 57 | - **Repo name rules:** `OrgName`'s, plus `.` and `_` for names like `.github` and |
| 58 | `foo.js`. Lowercased, max 100. Also rejects a name of nothing but dots and any name |
| 59 | ending `.git` — the first is traversal, the second would live at `foo.git.git`. |
| 60 | - **Reserved repo names:** `import`, `new`, `search`. Only names directly under |
| 61 | `/{handle}/repos/` can collide. |
| 62 | - **Visibility defaults to public**, matching a portfolio-first product. |
| 63 | - **Repositories carry an optional description**, capped at 300 characters — a sentence |
| 64 | for the profile listing, not a README. Kept deliberately: portfolio-first is the |
| 65 | tie-break, and this milestone's own "Done when" puts repositories on the profile, so |
| 66 | the consumer is inside the milestone rather than hypothetical. The per-repo analogue |
| 67 | of `Organization::bio`. |
| 68 | - **`/api/users/{handle}/repos` answers with a bare array**, not an |
| 69 | `{"repositories": […]}` envelope, matching `/api/users/{handle}` returning a bare |
| 70 | object. The cost is knowingly taken: adding pagination later means a wrapper and a |
| 71 | breaking change. Personal-first means a handful of repositories, and the API has no |
| 72 | consumers to break yet. |
| 73 | - **`/api` resolves the handle without loading a profile.** `handle_param` was split |
| 74 | out of `profile_for` so the listing route can 404 on `list_repos`' own `None` rather |
| 75 | than on a profile lookup it does not otherwise need — which is what that `Option` was |
| 76 | given a meaning for. |
| 77 | |
| 78 | - **`list_by_org` returns every repository regardless of visibility.** Filtering is an |
| 79 | authorization decision and belongs to the use case, so the page and `/api` cannot end |
| 80 | up applying different rules. The cost is that a private repo is briefly in memory |
| 81 | before being filtered, which is fine in-process. |
| 82 | |
| 83 | ### Open |
| 84 | |
| 85 | Nothing open. `GitStorage`'s shape and how git is invoked are recorded in |
| 86 | [0006](decisions/0006-git-binary-behind-narrow-ports.md). |
| 87 | |
| 88 | ### Watch for |
| 89 | |
| 90 | - **`redirect()` is a 307 and re-POSTs.** Post/redirect/get needs a 303. `see_other()` |
| 91 | is the right status but is a *response* type, and `#[page]` must return a view so the |
| 92 | layout can wrap a failure re-render. The way to get both from one handler is a |
| 93 | `StatusCode` and a `Location` pair inside `view!` — wrapped as |
| 94 | `context::location`. This shipped broken in Milestone 2's settings form and was |
| 95 | caught by browser verification, not by any test. |
| 96 | |
| 97 | - **An orphaned directory is indistinguishable from a duplicate to the visitor.** |
| 98 | `create_repo` maps `GitStorageError::AlreadyExists` to "that name is taken", which is |
| 99 | true from outside but hides the inconsistency from the operator. There is no logging |
| 100 | story yet for it to surface in. The durable fix is the reconciliation sweep in |
| 101 | [architecture.md](architecture.md#db-plus-filesystem-writes). |
| 102 | - **The duplicate check races.** Two concurrent creates of the same name can both pass |
| 103 | `find_by_org_and_name`; the loser is then stopped by `init_bare` or, failing that, by |
| 104 | the `unique (org_id, name)` constraint — which surfaces as an opaque storage error |
| 105 | rather than "name taken". Correct, just ugly, and single-user for now. |
| 106 | |
| 107 | - **The database and the filesystem cannot share a transaction.** Creating a repo |
| 108 | writes a row and a directory. Neither previous attempt solved this properly — see |
| 109 | [architecture.md](architecture.md#db-plus-filesystem-writes). A compensating delete is |
| 110 | good enough to ship, but write down that an orphaned directory is possible if the |
| 111 | process dies between the two, rather than rediscovering it. |
| 112 | - **Path traversal.** `{data_dir}/{handle}/{name}.git` is built from user input. A name |
| 113 | containing `..` or `/` must be impossible before it reaches the filesystem, and |
| 114 | `RepoName` is the place to make it impossible rather than sanitising at the call site. |
| 115 | - **Visibility is an authorization decision**, so it belongs in the use case. A private |
| 116 | repo must be absent from listings, not merely unlinked — and `/api` must agree with |
| 117 | the page. |
| 118 | - **`git` is a dependency of the test suite too**, not only of the runtime — |
| 119 | `DiskGitStorage`'s tests run real `git init`. A machine without `git` fails |
| 120 | `cargo test`, not just the app. |
| 121 | - **A handle rename is a directory move.** The layout is keyed by handle for |
| 122 | legibility, so whenever renaming arrives it has to move the tree; it cannot be a row |
| 123 | update. Nothing renames handles today. |
| 124 | - **Bare repos created on macOS carry `ignorecase = true`** in their config, because |
| 125 | git probes the filesystem at init. Correct where it was created, wrong if the data |
| 126 | directory is ever moved to Linux. A migration gotcha, not a bug. |
| 127 | - **An orphaned directory blocks re-creating that name.** `init_bare` refuses rather |
| 128 | than adopting what is already there, and repo deletion is out of scope this |
| 129 | milestone, so clearing one is a manual `rm` for now. |
| 130 | |
| 131 | ### Carried over — small, unblocked |
| 132 | |
| 133 | - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back |
| 134 | today. Topcoat's `font-fontsource` feature handles it. |
| 135 | - **Light mode is untested.** The palette defines it; nobody has looked at it. |
| 136 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
| 137 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 138 | enforced on read, so this is tidiness, not a hole. |
| 139 | - **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable |
| 140 | rather than hypothetical. |
| 141 | |
| 142 | ## Backlog |
| 143 | |
| 144 | Ordered. Pull from the top. |
| 145 | |
| 146 | 1. **Milestone 4 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP |
| 147 | Basic. See [0001](decisions/0001-git-over-http-not-ssh.md). The `body_limit` cap will |
| 148 | reject large pushes until raised. |
| 149 | 2. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value |
| 150 | objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port |
| 151 | returning `String`s is an anaemic pass-through that pushes validation into the page. |
| 152 | Also the point to measure fork/exec cost per page view, and to reconsider `gix` for |
| 153 | the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)). |
| 154 | 3. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open |
| 155 | whether writing or projects/showcases is the better first portfolio feature. |
| 156 | |
| 157 | ## Open questions |
| 158 | |
| 159 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 160 | by its own authors). Expect churn that isn't feature work. |
| 161 | - Body size limits will reject large pushes at Milestone 4 — `topcoat-router` has a |
| 162 | `body_limit` layer that needs raising on the git routes. Recorded here because it |
| 163 | will surface as a confusing failure rather than a clear one. |
| 164 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 165 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 166 | |
| 167 | ## Routing findings (Milestone 0) |
| 168 | |
| 169 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 170 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 171 | stable is now 1.97.1. |
| 172 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 173 | so pages can live in any module. Layering is our choice, not the framework's. |
| 174 | - `module_router!` derives each URL from the module tree rather than a path string. |
| 175 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 176 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 177 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 178 | `module_router!` handles that before committing to it. |
| 179 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 180 | injected as handler arguments. Parses are memoized per request. |
| 181 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 182 | `NotFoundError` to render a branded 404. |
| 183 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 184 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 185 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
| 186 | stream both directions without buffering. This is what makes Milestone 4 viable. |