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