fabrica

hanna/fabrica

11260 bytes

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:

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

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:

  1. Scaffold (flake + fenix + crane, empty crates, lints, justfile, MPL-2.0) 2. config 3. store
  2. 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 via bollard).