fabrica

hanna/fabrica

11260 bytes
1# CLAUDE.md
2
3This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5## Project status: MVP complete
6
7All 18 implementation phases are done and `nix flake check` is green. The workspace,
8flake, and every crate exist and are tested; the binary serves the web UI + HTTPS/SSH
9git transport + JSON API. `spec.md` remains the source of truth for behaviour — read it
10before 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
12deviation, e.g. rpgp-not-sequoia, native-tls-not-rustls, russh's aws-lc-rs licenses, and
13the `tokio` opt-level=0 workaround for a nightly ICE). Known gaps are noted there too;
14the 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)
17and SSH (read + write) with a fast, themeable, htmx-driven web UI. Private by default, no sign-up.
18Build order is fixed — see **Implementation phases** below.
19
20## Scope: a complete forge, minus CI
21
22The project now aims to be a **complete forge minus CI** (feature-comparable to Forgejo/Gitea for
23the 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
25labels** (`kind: value`), assignees, comments, and **server-side merge machinery** (merge/squash/
26rebase) — issues and PRs are **toggleable per repository**; optional web **self-registration**
27(toggleable, optional hCaptcha/reCAPTCHA) alongside admin invites; and a full **account settings**
28area (username/email/password, SSH/GPG keys, API tokens, profile). See `spec.md` §§18–19.
29
30Still **out of scope** (choose the simpler behaviour when ambiguous): GitHub-style orgs, forks,
31stars, followers, notifications, wikis, releases-as-a-feature, git-LFS, webhooks, mirroring,
32federation. CI remains **deferred** — design the seams (reserve the `runs` nav slot, `/-/runs`
33routes, `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
38None run today (no code). Once the workspace exists, the **quality gate** below must pass after
39every unit of work — no exceptions, no deferrals:
40
41```
42cargo fmt --all -- --check
43cargo check --workspace --all-targets
44cargo clippy --workspace --all-targets -- -D warnings
45cargo test --workspace
46```
47
48A `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
51suffice 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
54Toolchain: Rust **nightly** via fenix (pinned in `flake.lock`), edition 2024, resolver 3. Nightly
55is a currency preference only — **no `#![feature(...)]` gates**; code must compile on stable.
56Workspace 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
61Single root package `fabrica` (`src/main.rs` = arg-parse → dispatch into `cli`, nothing else).
62All library code lives in `crates/` with **unprefixed** names. Dependency direction is strictly
63one-way and enforced:
64
65```
66model ← 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
72Crates: `config` (figment TOML+env), `store` (sqlx, SQLite+Postgres), `model` (domain types, no
73I/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
87Therefore the `git` binary **MUST** be on `PATH` at runtime — the Nix derivation wraps the binary
88with git on PATH, the Dockerfile verifies `git --version` at build time. Resolve it once at
89startup (config `git.binary`, require ≥ 2.41), surface in `/healthz` and `fabrica doctor`.
90
91Pack transport rules that are easy to get wrong: **authorize before spawning** (the subprocess
92never makes an access decision); **stream both directions, never buffer a pack in memory**; HTTP
93push (`git-receive-pack`) returns a deliberate `403` pointing at the SSH URL; dumb HTTP is
94disabled. 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
98On disk: `{storage.repo_dir}/{id[0..2]}/{id}.git` where `id` is a lowercase ULID. The **database
99is the only name→path authority**, so renames and group moves are metadata-only. The tree is not
100human-browsable by design — `fabrica repo path <user> <name>` resolves it for operators.
101
102### Groups make repo paths variable-length → the `/-/` separator
103
104Group nesting means repo paths have unknown depth. Routes disambiguate with the GitLab `/-/`
105convention: 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)`;
107on miss look up `groups` and render a group listing; on miss `404`.
108
109### Store portability (SQLite + Postgres, one schema)
110
111Do **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
113portable SQL subset. Timestamps are `BIGINT` unix **milliseconds** UTC (no `DATETIME`/`TIMESTAMPTZ`).
114Ids 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.
116Migrations: `migrations/{sqlite,postgres}/NNNN_name.sql`, selected at runtime via `sqlx::migrate!`.
117
118### Permission model: one function, exhaustively tested
119
120```rust
121pub enum Access { None, Read, Write, Admin }
122pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access
123```
124
125Order: 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
127web UI and the API alike). The `access()` test is the security-critical one: enumerate the full
128matrix (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
133independently valid balanced HTML** (spans crossing a newline are split and the capture stack
134re-opened on the next line — see `fix(highlight): reopen capture stack across line breaks`), and
135diffs highlight the **pre- and post-image of each file as whole documents**, never per-hunk, then
136zip lines with hunks. Stable theme-driven class names (`hl-keyword`, `hl-string`, …) mapped from
137tree-sitter captures in one table. Highlighting **MUST NOT** be able to fail a page render — skip
138oversized/binary/minified files and fall back to escaped plain text on any grammar panic.
139Language resolution has a fixed precedence (§8.1): `.gitattributes fabrica-language` > `linguist-
140language` > 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
145npm, 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
147without 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,
149partial)` helper so no route drifts.
150
151Theming re-brands with no rebuild: `assets/base.css` is structure-only with **no literal colours**,
152written entirely against custom properties. Themes are single CSS files defining `--fb-*` tokens;
153drop one in `ui.themes_dir` and `SIGHUP` to load it. A theme that sets only the documented `--fb-*`
154tokens MUST yield a complete UI — that is the themeability acceptance test. No web fonts, no network
155requests from any page.
156
157## CLI operates directly on the store — no IPC with the server
158
159Every `fabrica` subcommand touches the store/filesystem directly (SQLite WAL and Postgres both
160tolerate concurrent writers); there is no IPC with a running `serve`. Mutations a live server
161caches (repo rename/delete, user disable) bump a `cache_epoch` row that `serve` polls. `serve` runs
162in the **foreground** by default (correct for systemd/Docker); there is no `stop` subcommand —
163signal 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
181Each phase ends with the full gate green and its own commits; do not start a phase before the
182previous is clean:
183
1841. Scaffold (flake + fenix + crane, empty crates, lints, justfile, MPL-2.0) 2. config 3. store
1854. auth 5. git reads 6. highlight 7. git signatures 8. cli core 9. mail 10. web shell 11. web repo
186views 12. web commit view 13. pack transport 14. ssh 15. search 16. api 17. deployment 18. polish.
187Then, and only then, CI (HCL config via `hcl-rs`, Docker execution via `bollard`).