| 1 | # CLAUDE.md |
| 2 | |
| 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | |
| 5 | ## Project status: MVP complete |
| 6 | |
| 7 | All 18 implementation phases are done and `nix flake check` is green. The workspace, |
| 8 | flake, and every crate exist and are tested; the binary serves the web UI + HTTPS/SSH |
| 9 | git transport + JSON API. `spec.md` remains the source of truth for behaviour — read it |
| 10 | before changing anything. Where it says **MUST**, treat it as a hard requirement; |
| 11 | **SHOULD** deviations are logged in `docs/decisions.md` (read it — it records every |
| 12 | deviation, e.g. rpgp-not-sequoia, native-tls-not-rustls, russh's aws-lc-rs licenses, and |
| 13 | the `tokio` opt-level=0 workaround for a nightly ICE). Known gaps are noted there too; |
| 14 | the deferred **CI** (HCL + Docker via bollard) has not been started. |
| 15 | |
| 16 | `fabrica` is a self-hosted git server: a single Rust binary that serves repos over HTTPS (read) |
| 17 | and SSH (read + write) with a fast, themeable, htmx-driven web UI. Private by default, no sign-up. |
| 18 | Build order is fixed — see **Implementation phases** below. |
| 19 | |
| 20 | ## Scope: a complete forge, minus CI |
| 21 | |
| 22 | The project now aims to be a **complete forge minus CI** (feature-comparable to Forgejo/Gitea for |
| 23 | the areas below). In scope: code browsing/diffs/signatures, nested groups, three-level visibility |
| 24 | (public/internal/private) + collaborators, **issues and pull requests** with GitHub-style **scoped |
| 25 | labels** (`kind: value`), assignees, comments, and **server-side merge machinery** (merge/squash/ |
| 26 | rebase) — issues and PRs are **toggleable per repository**; optional web **self-registration** |
| 27 | (toggleable, optional hCaptcha/reCAPTCHA) alongside admin invites; and a full **account settings** |
| 28 | area (username/email/password, SSH/GPG keys, API tokens, profile). See `spec.md` §§18–19. |
| 29 | |
| 30 | Still **out of scope** (choose the simpler behaviour when ambiguous): GitHub-style orgs, forks, |
| 31 | stars, followers, notifications, wikis, releases-as-a-feature, git-LFS, webhooks, mirroring, |
| 32 | federation. CI remains **deferred** — design the seams (reserve the `runs` nav slot, `/-/runs` |
| 33 | routes, `runs`/`jobs` tables, `post-receive` hook entry point), ship them inert behind |
| 34 | `ui.show_runs = false`, and build CI last. |
| 35 | |
| 36 | ## Commands |
| 37 | |
| 38 | None run today (no code). Once the workspace exists, the **quality gate** below must pass after |
| 39 | every unit of work — no exceptions, no deferrals: |
| 40 | |
| 41 | ``` |
| 42 | cargo fmt --all -- --check |
| 43 | cargo check --workspace --all-targets |
| 44 | cargo clippy --workspace --all-targets -- -D warnings |
| 45 | cargo test --workspace |
| 46 | ``` |
| 47 | |
| 48 | A `justfile` must expose `just check` running all four; use it. `nix flake check` is the superset |
| 49 | (`checks.{clippy,fmt,test,deny,doc}`) and must be green before any phase is called done. Tests use |
| 50 | `cargo-nextest` in the dev shell; keep the full suite under two minutes. `cargo test` alone must |
| 51 | suffice with no external services (Postgres tests are feature-gated behind `postgres-tests` / |
| 52 | `FABRICA_TEST_POSTGRES_URL`; integration tests gate on `git`/`ssh` being present and skip cleanly). |
| 53 | |
| 54 | Toolchain: Rust **nightly** via fenix (pinned in `flake.lock`), edition 2024, resolver 3. Nightly |
| 55 | is a currency preference only — **no `#![feature(...)]` gates**; code must compile on stable. |
| 56 | Workspace lints forbid `unsafe_code`; deny `clippy::all`, `unwrap_used`, `panic`, `todo`. |
| 57 | `unwrap_used` may be locally allowed inside `#[cfg(test)]`. |
| 58 | |
| 59 | ## Architecture |
| 60 | |
| 61 | Single root package `fabrica` (`src/main.rs` = arg-parse → dispatch into `cli`, nothing else). |
| 62 | All library code lives in `crates/` with **unprefixed** names. Dependency direction is strictly |
| 63 | one-way and enforced: |
| 64 | |
| 65 | ``` |
| 66 | model ← store / git / auth ← web / api / ssh / cli |
| 67 | ``` |
| 68 | |
| 69 | `model` depends on nothing in-tree; **no crate may depend on `web`**. Never name a crate `core`, |
| 70 | `std`, `alloc`, `test`, or `proc_macro` (sysroot collisions). |
| 71 | |
| 72 | Crates: `config` (figment TOML+env), `store` (sqlx, SQLite+Postgres), `model` (domain types, no |
| 73 | I/O), `git` (libgit2 reads + subprocess pack transport), `highlight` (inkjet/tree-sitter), |
| 74 | `auth` (argon2id, sessions, JWT), `mail` (lettre), `ssh` (russh), `web` (axum + maud + htmx), |
| 75 | `api` (`/api/v1` JSON), `cli` (clap). |
| 76 | |
| 77 | ### The central architectural fact: git plumbing is split |
| 78 | |
| 79 | `libgit2` (via `git2`) is **client-only for transport** — it has no server-side `upload-pack`/ |
| 80 | `receive-pack`. So: |
| 81 | |
| 82 | - **In-process `git2`**: browsing, log, diff, refs/trees/blobs, signature extraction, `init_bare`. |
| 83 | - **Spawned `git` subprocess**: clone/fetch (`git upload-pack --stateless-rpc`), push |
| 84 | (`git receive-pack`), maintenance (`git gc`/`git repack`). One code path: |
| 85 | `git::pack::spawn(kind, repo_path, env, stdio)`. |
| 86 | |
| 87 | Therefore the `git` binary **MUST** be on `PATH` at runtime — the Nix derivation wraps the binary |
| 88 | with git on PATH, the Dockerfile verifies `git --version` at build time. Resolve it once at |
| 89 | startup (config `git.binary`, require ≥ 2.41), surface in `/healthz` and `fabrica doctor`. |
| 90 | |
| 91 | Pack transport rules that are easy to get wrong: **authorize before spawning** (the subprocess |
| 92 | never makes an access decision); **stream both directions, never buffer a pack in memory**; HTTP |
| 93 | push (`git-receive-pack`) returns a deliberate `403` pointing at the SSH URL; dumb HTTP is |
| 94 | disabled. A reverse proxy in front **must not buffer** `/git-upload-pack` bodies or clones stall. |
| 95 | |
| 96 | ### Repos are stored by id, not name |
| 97 | |
| 98 | On disk: `{storage.repo_dir}/{id[0..2]}/{id}.git` where `id` is a lowercase ULID. The **database |
| 99 | is the only name→path authority**, so renames and group moves are metadata-only. The tree is not |
| 100 | human-browsable by design — `fabrica repo path <user> <name>` resolves it for operators. |
| 101 | |
| 102 | ### Groups make repo paths variable-length → the `/-/` separator |
| 103 | |
| 104 | Group nesting means repo paths have unknown depth. Routes disambiguate with the GitLab `/-/` |
| 105 | convention: everything before `/-/` is the repo path, everything after is the view |
| 106 | (`/{owner}/{path..}/-/blob/{ref}/{p..}`). Path resolution: look up `repos` by `(owner_id, path)`; |
| 107 | on miss look up `groups` and render a group listing; on miss `404`. |
| 108 | |
| 109 | ### Store portability (SQLite + Postgres, one schema) |
| 110 | |
| 111 | Do **not** use sqlx compile-time-checked macros against two dialects. Define a `Store` trait with |
| 112 | `Sqlite`/`Postgres` impls (or runtime queries over `AnyPool`); every statement stays in the |
| 113 | portable SQL subset. Timestamps are `BIGINT` unix **milliseconds** UTC (no `DATETIME`/`TIMESTAMPTZ`). |
| 114 | Ids are `TEXT` ULIDs (26 chars, lexicographically sortable — use for ordering). No `AUTOINCREMENT`/ |
| 115 | `SERIAL`. Case-insensitive uniqueness via a normalized `*_lower` column + unique index, not collation. |
| 116 | Migrations: `migrations/{sqlite,postgres}/NNNN_name.sql`, selected at runtime via `sqlx::migrate!`. |
| 117 | |
| 118 | ### Permission model: one function, exhaustively tested |
| 119 | |
| 120 | ```rust |
| 121 | pub enum Access { None, Read, Write, Admin } |
| 122 | pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access |
| 123 | ``` |
| 124 | |
| 125 | Order: admin → owner → explicit collaborator row → public+anonymous-allowed = Read → None. |
| 126 | `None` on a private repo **MUST render 404, not 403** — never leak existence (this holds for the |
| 127 | web UI and the API alike). The `access()` test is the security-critical one: enumerate the full |
| 128 | matrix (viewer kind × visibility × collaborator permission), don't sample it. |
| 129 | |
| 130 | ### Highlighting: line-addressable, per-side in diffs |
| 131 | |
| 132 | `inkjet`/tree-sitter. Two hard requirements drive the design: **every emitted line must be |
| 133 | independently valid balanced HTML** (spans crossing a newline are split and the capture stack |
| 134 | re-opened on the next line — see `fix(highlight): reopen capture stack across line breaks`), and |
| 135 | diffs highlight the **pre- and post-image of each file as whole documents**, never per-hunk, then |
| 136 | zip lines with hunks. Stable theme-driven class names (`hl-keyword`, `hl-string`, …) mapped from |
| 137 | tree-sitter captures in one table. Highlighting **MUST NOT** be able to fail a page render — skip |
| 138 | oversized/binary/minified files and fall back to escaped plain text on any grammar panic. |
| 139 | Language resolution has a fixed precedence (§8.1): `.gitattributes fabrica-language` > `linguist- |
| 140 | language` > shebang > modeline > exact filename > extension (longest match) > plain text. |
| 141 | |
| 142 | ### Web UI: server-rendered, htmx-enhanced, no build step |
| 143 | |
| 144 | `axum` + `maud` (compile-time-checked templates) + **htmx 2 vendored into `assets/`** (no CDN, no |
| 145 | npm, no WASM, no SPA). Hand-written vanilla JS is capped at < 200 lines total in `assets/fabrica.js` |
| 146 | (mobile drawer, theme picker, clipboard, keyboard shortcuts). **Every interactive element must work |
| 147 | without JavaScript** — htmx enhances links/forms, it is not the substrate. Handlers detect the |
| 148 | `HX-Request` header and return a partial, else the full page; write this as one `respond(page, |
| 149 | partial)` helper so no route drifts. |
| 150 | |
| 151 | Theming re-brands with no rebuild: `assets/base.css` is structure-only with **no literal colours**, |
| 152 | written entirely against custom properties. Themes are single CSS files defining `--fb-*` tokens; |
| 153 | drop one in `ui.themes_dir` and `SIGHUP` to load it. A theme that sets only the documented `--fb-*` |
| 154 | tokens MUST yield a complete UI — that is the themeability acceptance test. No web fonts, no network |
| 155 | requests from any page. |
| 156 | |
| 157 | ## CLI operates directly on the store — no IPC with the server |
| 158 | |
| 159 | Every `fabrica` subcommand touches the store/filesystem directly (SQLite WAL and Postgres both |
| 160 | tolerate concurrent writers); there is no IPC with a running `serve`. Mutations a live server |
| 161 | caches (repo rename/delete, user disable) bump a `cache_epoch` row that `serve` polls. `serve` runs |
| 162 | in the **foreground** by default (correct for systemd/Docker); there is no `stop` subcommand — |
| 163 | signal the pid. Destructive commands prompt on a TTY and require `--yes` otherwise. |
| 164 | |
| 165 | ## Working conventions |
| 166 | |
| 167 | - **Commits**: Conventional Commits, one logical change each, committed **as work happens** (not |
| 168 | batched). Scope is the crate/area: `feat(git):`, `fix(web):`, `build(nix):`. Imperative, lowercase, |
| 169 | ≤ 72 chars, no trailing period. **Never commit with failing checks** — a needed `#[allow]` is |
| 170 | justified in the commit body. |
| 171 | - **Errors**: `thiserror` (one enum per crate) in libraries; `anyhow` only at the binary boundary |
| 172 | (`main.rs`, CLI). `tracing` with structured fields — request-id span per HTTP request, session |
| 173 | span per SSH connection. User-facing error pages are templated/themed, never expose internals |
| 174 | (detail → log with the request id, page shows the id). |
| 175 | - **Docs**: `docs/decisions.md` is a running log (date, decision, alternatives, rationale). Seed it |
| 176 | with the decisions the spec already makes (subprocess pack transport, id-based repo dirs, `/-/` |
| 177 | separator, epoch-ms timestamps, `serve` with no `stop`, linear search). Update on any deviation. |
| 178 | |
| 179 | ## Implementation phases (build in this order) |
| 180 | |
| 181 | Each phase ends with the full gate green and its own commits; do not start a phase before the |
| 182 | previous is clean: |
| 183 | |
| 184 | 1. Scaffold (flake + fenix + crane, empty crates, lints, justfile, MPL-2.0) 2. config 3. store |
| 185 | 4. auth 5. git reads 6. highlight 7. git signatures 8. cli core 9. mail 10. web shell 11. web repo |
| 186 | views 12. web commit view 13. pack transport 14. ssh 15. search 16. api 17. deployment 18. polish. |
| 187 | Then, and only then, CI (HCL config via `hcl-rs`, Docker execution via `bollard`). |