fabrica

hanna/fabrica

60288 bytes

fabrica — implementation spec

fabrica (Latin: forge) is a self-hosted git server. Single binary, private by default, no sign-up. It serves repositories over HTTPS (read) and SSH (read + write) and provides a fast, themeable web UI for browsing code.

This document is the implementation contract. Follow it section by section. Where it says MUST, treat it as a hard requirement; where it says SHOULD, deviate only with a note in docs/decisions.md.


1. Goals and non-goals

Goals

The aim is a complete forge minus CI — feature-comparable to Forgejo/Gitea for the areas above. When a feature is ambiguous, choose the simpler behaviour.

Non-goals (do not build these)

Deferred (design for, do not implement)


2. Toolchain, build, and repository layout

2.1 Workspace

Root package fabrica produces the single binary. All library code lives in crates/, with unprefixed crate names:

fabrica/
├── flake.nix
├── flake.lock
├── Cargo.toml                # workspace root, package `fabrica`, src/main.rs only
├── Cargo.lock
├── rust-toolchain.toml
├── src/main.rs               # arg parse -> dispatch into `cli`; nothing else
├── crates/
│   ├── config/               # TOML + env config load, validation, path resolution
│   ├── store/                # database: schema, migrations, queries (SQLite + Postgres)
│   ├── model/                # shared domain types, no I/O
│   ├── git/                  # libgit2 reads, attributes, signatures, pack transport spawn
│   ├── highlight/            # inkjet/tree-sitter, language resolution, line-aware output
│   ├── auth/                 # argon2id, sessions, JWT, permission checks
│   ├── mail/                 # SMTP + templates
│   ├── ssh/                  # russh server, publickey auth, exec -> pack processes
│   ├── web/                  # axum router, maud templates, htmx partials, assets, theming
│   ├── api/                  # /api/v1 JSON handlers
│   └── cli/                  # clap command tree, all subcommands
├── assets/                   # embedded defaults (base.css, theme.css, htmx.min.js, icons)
├── migrations/
│   ├── sqlite/
│   └── postgres/
├── docs/
│   ├── configuration.md
│   ├── theming.md
│   ├── deployment.md
│   └── decisions.md
├── Dockerfile
├── compose.yml
├── fabrica.example.toml
└── LICENSE                   # MPL-2.0

Naming constraints: MUST NOT name a crate core, std, alloc, test, or proc_macro — these collide with sysroot crates. git and api are fine.

Dependency direction is strictly one-way: modelstore/git/authweb/api/ssh/cli. No crate may depend on web. model depends on nothing in-tree.

2.2 Toolchain

[workspace.lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = "warn"
unwrap_used = "deny"
expect_used = "warn"
panic = "deny"
todo = "deny"

unwrap_used is denied in library code; #[cfg(test)] blocks may allow it locally.

2.3 Nix flake

Inputs: nixpkgs (unstable), fenix, crane, flake-utils (or flake-parts — pick one and be consistent).

The flake MUST expose:

Build shape:

craneLib = (crane.mkLib pkgs).overrideToolchain fenixToolchain;
commonArgs = {
  src = craneLib.cleanCargoSource ./.;
  strictDeps = true;
  nativeBuildInputs = [ pkgs.pkg-config pkgs.makeWrapper ];
  buildInputs = [ pkgs.libgit2 pkgs.zlib pkgs.openssl ];
  # use system libgit2 rather than the vendored copy
  LIBGIT2_NO_VENDOR = "1";
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;

The final derivation MUST wrap the binary so the git plumbing is always reachable:

postInstall = ''
  wrapProgram $out/bin/fabrica \
    --prefix PATH : ${lib.makeBinPath [ pkgs.git ]}
'';

Also expose a NixOS module at nixosModules.default providing services.fabrica with enable, settings (TOML-typed, rendered via pkgs.formats.toml), user, group, dataDir, environmentFile (for secrets), a hardened systemd.services.fabrica unit (DynamicUser off, StateDirectory=fabrica, ProtectSystem=strict, NoNewPrivileges, AmbientCapabilities=CAP_NET_BIND_SERVICE only if a privileged port is configured), and openFirewall.


3. Git layer (crates/git)

3.1 Division of responsibility — read this carefully

libgit2 (via git2) is the object-model library: refs, trees, blobs, commits, revwalks, diffs, attributes, signature extraction. libgit2 does not implement the server side of the pack protocol. There is no upload-pack/receive-pack in libgit2 — it is client-only for transport. Therefore:

Concern Implementation
Browsing, log, diff, blame-free reads git2 in-process
Signature extraction git2 (Repository::extract_signature)
Attribute resolution own resolver over tree blobs (§3.5)
Clone/fetch (HTTPS) spawn git upload-pack --stateless-rpc
Push (SSH) spawn git receive-pack
Fetch over SSH spawn git upload-pack
Repo creation, gc, repack git2 for init; spawn git gc/git repack for maintenance

git MUST be present on PATH at runtime. Resolve it once at startup:

Record the discovered path and version in /healthz output and in fabrica doctor.

3.2 On-disk layout

Repositories are stored by id, not by name, so renames and group moves never touch the filesystem:

{storage.repo_dir}/{id[0..2]}/{id}.git

where id is a lowercase ULID. The database is the only name→path authority. Document the tradeoff in docs/decisions.md: the tree is not human-browsable, in exchange for atomic, metadata-only renames. fabrica repo path <user> <name> prints the resolved directory for operators.

Repo creation:

  1. git2::Repository::init_bare.
  2. Set HEAD to refs/heads/{default_branch} (config: instance.default_branch, default main).
  3. Write config: core.logAllRefUpdates=true, receive.denyNonFastForwards=false, gc.auto=0 (maintenance is driven by fabrica, not by pushes).
  4. Install hooks (§3.4).
  5. Insert the DB row in the same transaction; on any failure, remove the directory.

Deletion moves the directory to {storage.data_dir}/trash/{id}-{timestamp}.git and deletes the row; a fabrica gc --trash-older-than 30d command reaps it. repo del --purge skips the trash.

3.3 Pack transport

Everything below shares one code path: git::pack::spawn(kind, repo_path, env, stdio).

Common rules

HTTPS smart protocol (read-only)

Implement only the v0/v1/v2 smart paths; dumb HTTP MUST be disabled.

GET  /{owner}/{path}.git/info/refs?service=git-upload-pack
POST /{owner}/{path}.git/git-upload-pack

SSH (read + write)

crates/ssh runs a russh server.

3.4 Hooks

Install hooks/post-receive (and a no-op pre-receive reserved for future branch protection) as a two-line script:

#!/bin/sh
exec fabrica hook post-receive

fabrica hook post-receive reads the <old> <new> <ref> lines from stdin and:

The hook path must locate the running binary reliably: write the absolute path of the current executable at repo-init time (std::env::current_exe), falling back to git.hook_binary config.

3.5 Attributes (.gitattributes)

libgit2's attribute lookup is oriented toward a working tree; bare repos and per-ref accuracy make it unreliable here. Implement an own resolver:

Unit tests MUST cover: precedence between nested files, negation, anchored vs floating patterns, and -diff suppressing diff rendering.

3.6 Signature verification

Read the signature with Repository::extract_signature(oid, None), which yields (signature, signed_payload). Dispatch on the armor header.

SSH (-----BEGIN SSH SIGNATURE-----): parse with the ssh-key crate's SshSig. Verify with namespace "git" against candidate public keys. Reject if the sig's hash algorithm is not sha256/sha512.

GPG (-----BEGIN PGP SIGNATURE-----): verify with sequoia-openpgp against registered public keys. Check the signature's creation time against key validity and expiry; treat expired or revoked keys as unverified, not verified.

Result type:

pub enum SignatureState {
    Unsigned,
    /// Valid signature, key registered on this instance.
    Verified { user_id: Ulid, key_id: Ulid, kind: KeyKind, fingerprint: String },
    /// Valid signature, key not known to this instance.
    UnknownKey { kind: KeyKind, fingerprint: String },
    /// Signature present but cryptographically invalid, malformed, or expired.
    Invalid { kind: Option<KeyKind>, reason: String },
}

UI mapping, matching the reference screenshots: Verified renders a green Verified badge; everything else that carries a signature renders an amber Unverified badge; Unsigned renders an amber Unverified badge as well. The tooltip / detail line distinguishes the four cases precisely ("Signed by an unrecognized key SHA256:…", "Signature could not be verified: …", "This commit is not signed"). Do not silently collapse Invalid into Unsigned.

Verification is expensive; cache per commit oid in a bounded moka cache and, for list views, verify lazily in a bounded-concurrency batch (ui.signature_batch_concurrency, default 8). Additionally verify tag signatures for annotated tags and show the same badge on the tags page.

3.7 Reads

Expose a narrow, testable API — no git2 types in the public signatures, so web never links against libgit2 semantics:

pub fn resolve_ref(&self, r: &str) -> Result<Resolved>;           // branch | tag | oid | HEAD
pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result<Vec<TreeEntry>>;
pub fn blob(&self, rev: &Resolved, path: &Path) -> Result<Blob>;  // + is_binary, size, lfs-ignored
pub fn commits(&self, rev: &Resolved, path: Option<&Path>, page: Page) -> Result<Vec<CommitSummary>>;
pub fn commit(&self, oid: Oid) -> Result<CommitDetail>;
pub fn diff(&self, base: Option<Oid>, head: Oid, opts: DiffOpts) -> Result<FileDiffs>;
pub fn diff_context(&self, head: Oid, file: &Path, from: u32, to: u32) -> Result<Vec<DiffLine>>;
pub fn branches(&self) -> Result<Vec<BranchInfo>>;                // + ahead/behind vs default
pub fn tags(&self) -> Result<Vec<TagInfo>>;
pub fn last_commit_per_entry(&self, rev: &Resolved, path: &Path) -> Result<HashMap<..>>;

Notes:


4. Configuration (crates/config)

TOML file plus environment overrides via figment. Resolution order (later wins): built-in defaults → /etc/fabrica/fabrica.toml$XDG_CONFIG_HOME/fabrica/fabrica.toml--config <path>FABRICA__* env vars. Nested keys use double underscores: FABRICA__SERVER__PORT=8080.

Any *_file key reads its value from a file (for agenix/systemd-creds/Docker secrets) and takes precedence over its inline sibling. Secrets MUST NOT be logged, echoed by config show (print <redacted>), or included in error messages.

fabrica.example.toml — ship this, fully commented:

[instance]
name            = "fabrica"          # shown in the header and page titles
url             = "https://git.example.com"
description     = "A small, private forge."
default_branch  = "main"
allow_anonymous = true               # anonymous browse + clone of public repos

[server]
address                = "0.0.0.0"
port                   = 8080
behind_proxy           = false       # trust X-Forwarded-For / X-Forwarded-Proto
graceful_shutdown_secs = 20
max_body_bytes          = 1048576    # non-pack routes only

[ssh]
enabled       = true
address       = "0.0.0.0"
port          = 2222
host_key_path = "/var/lib/fabrica/ssh/host_ed25519"
clone_host    = "git.example.com"    # what the UI shows, may differ from bind
clone_port    = 22

[storage]
data_dir = "/var/lib/fabrica"
repo_dir = "/var/lib/fabrica/repos"

[database]
url             = "sqlite:///var/lib/fabrica/fabrica.db"
max_connections = 16
# SQLite is opened with journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON,
# busy_timeout=5000. Postgres URLs (postgres://…) are detected automatically.

[auth]
secret_file       = "/var/lib/fabrica/secret"   # HS256 key, generated on first run, 0600
session_ttl_days  = 30
token_ttl_days    = 90
cookie_name       = "fabrica_session"
cookie_secure     = true
argon2_m_cost     = 19456
argon2_t_cost     = 2
argon2_p_cost     = 1

[mail]
backend       = "smtp"               # smtp | stdout | none
from          = "fabrica@example.com"
host          = "smtp.example.com"
port          = 587
username      = "fabrica"
password_file = "/run/secrets/fabrica-smtp"
encryption    = "starttls"           # starttls | tls | none
timeout_secs  = 15

[ui]
theme                = "dark"        # file stem in themes_dir, or an embedded theme name
themes_dir           = "/var/lib/fabrica/themes"
assets_dir           = "/var/lib/fabrica/assets"
allow_theme_choice   = true          # show the theme picker; persists in a cookie
show_runs            = false         # CI nav slot, inert until the CI ships
diff_context_lines   = 3
max_highlight_bytes  = 1048576
max_diff_bytes       = 5242880
tree_history_limit   = 1000

[git]
binary                 = "git"
transport_timeout_secs = 3600
max_pack_size_bytes    = 536870912

[log]
level  = "info"                      # trace|debug|info|warn|error, RUST_LOG also honoured
format = "pretty"                    # pretty | json

Validation at startup MUST be strict and fail fast with actionable messages: unknown keys are an error (catch typos), instance.url must parse and must not have a trailing slash, directories must exist or be creatable, mail.backend = "smtp" requires host/from, auth.cookie_secure = true with a non-HTTPS instance.url is a warning.

fabrica config check validates and exits; fabrica config show prints the effective merged config with secrets redacted.


5. Data model (crates/store)

sqlx with both backends. Do not use compile-time-checked macros against two dialects — define a Store trait with Sqlite and Postgres implementations behind it, or use runtime queries over AnyPool; keep every statement inside the portable SQL subset.

Portability rules, enforced by review:

Migrations live in migrations/{sqlite,postgres}/NNNN_name.sql, applied with sqlx::migrate! selected at runtime. fabrica migrate runs them; serve runs them automatically unless --no-migrate.

Schema

users (
  id TEXT PRIMARY KEY,
  username TEXT NOT NULL, username_lower TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL, email_lower TEXT NOT NULL UNIQUE,
  display_name TEXT,
  password_hash TEXT,                 -- NULL until the invite is completed
  must_change_password INTEGER NOT NULL DEFAULT 0,
  is_admin INTEGER NOT NULL DEFAULT 0,
  disabled_at BIGINT,
  created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL
)

invites (                             -- password-setup / activation links
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash TEXT NOT NULL UNIQUE,    -- SHA-256 of the emailed token
  purpose TEXT NOT NULL,              -- 'activate' | 'reset'
  expires_at BIGINT NOT NULL, used_at BIGINT,
  created_at BIGINT NOT NULL
)

sessions (
  id TEXT PRIMARY KEY,                -- SHA-256 of the cookie value
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  user_agent TEXT, ip TEXT,
  created_at BIGINT NOT NULL, last_seen_at BIGINT NOT NULL, expires_at BIGINT NOT NULL
)

api_tokens (
  id TEXT PRIMARY KEY,                -- the JWT `jti`
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  scopes TEXT NOT NULL,               -- comma-separated
  expires_at BIGINT, revoked_at BIGINT, last_used_at BIGINT,
  created_at BIGINT NOT NULL
)

keys (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  kind TEXT NOT NULL,                 -- 'ssh' | 'gpg'
  name TEXT,
  fingerprint TEXT NOT NULL,          -- SHA256:… for ssh, full hex fp for gpg
  public_key TEXT NOT NULL,           -- authorized_keys line or ASCII-armored pgp block
  ordinal INTEGER NOT NULL,           -- stable 1-based index per (user, kind) for `key del`
  last_used_at BIGINT,
  created_at BIGINT NOT NULL,
  UNIQUE (kind, fingerprint),
  UNIQUE (user_id, kind, ordinal)
)

groups (
  id TEXT PRIMARY KEY,
  owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  parent_id TEXT REFERENCES groups(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  path TEXT NOT NULL,                 -- materialized 'group/sub/leaf'
  description TEXT,
  created_at BIGINT NOT NULL,
  UNIQUE (owner_id, path)
)

repos (
  id TEXT PRIMARY KEY,
  owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
  name TEXT NOT NULL,
  path TEXT NOT NULL,                 -- materialized 'group/sub/repo' (== name when ungrouped)
  description TEXT,
  is_private INTEGER NOT NULL DEFAULT 1,
  default_branch TEXT NOT NULL,
  archived_at BIGINT,
  size_bytes BIGINT NOT NULL DEFAULT 0,
  pushed_at BIGINT,
  created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
  UNIQUE (owner_id, path)
)

repo_collaborators (
  repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  permission TEXT NOT NULL,           -- 'read' | 'write' | 'admin'
  created_at BIGINT NOT NULL,
  PRIMARY KEY (repo_id, user_id)
)

-- reserved, inert until the CI ships
runs  (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, ref TEXT NOT NULL, commit_sha TEXT NOT NULL,
       status TEXT NOT NULL, started_at BIGINT, finished_at BIGINT, created_at BIGINT NOT NULL)
jobs  (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
       stage TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL,
       log_path TEXT, started_at BIGINT, finished_at BIGINT)

Indexes: repos(owner_id, path), repos(is_private, pushed_at DESC), groups(owner_id, parent_id), keys(user_id, kind), sessions(user_id), sessions(expires_at), api_tokens(user_id).

Name validation (single place in model, used by CLI, API, and web): usernames and repo/group names match ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$, must not be ./.., must not end in .git or ., and are rejected against a reserved list: api, assets, login, logout, search, settings, admin, static, healthz, invite, -.


6. Auth and permissions (crates/auth)

Permission resolution — one function, used everywhere, unit tested exhaustively:

pub enum Access { None, Read, Write, Admin }
pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access

Rules, in order:

  1. viewer.is_adminAdmin.
  2. viewer.id == repo.owner_idAdmin.
  3. explicit repo_collaborators row → that permission.
  4. !repo.is_private && instance.allow_anonymousRead.
  5. otherwise None.

Anonymous viewers can only ever reach Read, and only on public repos. Every mutating route asserts at least Write; creation routes assert an authenticated viewer. None on a private repo MUST render 404, not 403 — do not leak existence.


7. Mail (crates/mail)

Required for the MVP: user add without --pass emails the user an activation link.


8. Highlighting (crates/highlight)

inkjet (tree-sitter) for highlighting. This crate has two hard requirements that drive its design: line-addressable output (for blob views with anchors) and per-side highlighting inside diffs.

Implementation:

  1. Resolve the language (§8.1) → inkjet::Language.
  2. Highlight the full text once, producing a flat sequence of (byte_range, capture_stack).
  3. Convert to Vec<HighlightedLine>, where each line is Vec<Span { class, text }>. Spans that cross a newline are split, and the open capture stack is re-opened on the next line. This is the crux: every emitted line must be independently valid HTML with balanced tags.
  4. Cache by (blob_oid, language) in a bounded moka cache.

For diffs, highlight the pre-image and post-image of each file as whole documents — never per-hunk, or context-dependent grammars produce garbage — then index the resulting lines by line number and zip them with the diff hunks. Added/removed backgrounds are applied to the row, and highlight spans live inside it, exactly as in the reference screenshots.

Class names are stable and theme-driven: hl-keyword, hl-function, hl-type, hl-string, hl-number, hl-comment, hl-constant, hl-variable, hl-operator, hl-punctuation, hl-attribute, hl-tag. Map inkjet's capture names onto this fixed set in one table so themes never need to know tree-sitter internals. Document the full list in docs/theming.md.

Guards: skip files over ui.max_highlight_bytes, skip files detected as binary (NUL in the first 8 KiB), skip minified files (any line > 5000 bytes), and fall back to escaped plain text on any grammar panic — highlighting MUST NOT be able to fail a page render. Enforce a per-file timeout and fall back on expiry.

8.1 Language resolution order

  1. .gitattributes fabrica-language=<name> (own attribute, highest precedence).
  2. .gitattributes linguist-language=<name> (GitHub compatibility).
  3. .gitattributes -diff / binary → no highlighting, render as binary.
  4. Shebang line (#!/usr/bin/env python3 → python).
  5. Vim/Emacs modeline in the first or last 5 lines.
  6. Exact filename (Dockerfile, Makefile, flake.lock, Cargo.lock, PKGBUILD).
  7. Extension, longest match first (.tar.gz before .gz, .d.ts before .ts).
  8. Plain text.

Name matching is case-insensitive with an alias table (rs/rust, py/python, ts/typescript, nix, gleam, hcl/terraform, …). An unknown language name in .gitattributes logs a debug line and falls through, rather than erroring.

Unit tests: one per resolution step, plus a test asserting fabrica-language beats linguist-language and both beat the extension.


9. Web UI (crates/web)

9.1 Stack

9.2 Routing

Group nesting makes repo paths variable-length, which collides with sub-resources. Resolve it with a /-/ separator (the GitLab convention): everything before /-/ is the repo path, everything after is the view.

/                                   home: your repos + public repos
/login  /logout  /invite/{token}
/search?q=&scope=                   global search
/settings                           profile, password
/settings/keys                      ssh + gpg keys
/settings/tokens                    API tokens
/admin/users                        admin only
/healthz  /version
/assets/*                           overridable assets
/{owner}                            user page: groups + repos
/{owner}/{path..}                   repo home (or group listing if path resolves to a group)
/{owner}/{path..}.git/*             pack transport (§3.3)
/{owner}/{path..}/-/tree/{ref}/{p..}
/{owner}/{path..}/-/blob/{ref}/{p..}
/{owner}/{path..}/-/raw/{ref}/{p..}
/{owner}/{path..}/-/commits/{ref}
/{owner}/{path..}/-/commit/{sha}
/{owner}/{path..}/-/commit/{sha}/context      (partial: expanded diff context)
/{owner}/{path..}/-/branches
/{owner}/{path..}/-/tags
/{owner}/{path..}/-/search?q=
/{owner}/{path..}/-/settings
/{owner}/{path..}/-/runs                      (inert; 404 unless ui.show_runs)

Path resolution: given {owner} and the segments before /-/, look up repos by (owner_id, path); on miss, look up groups by (owner_id, path) and render a group listing; on miss, 404. One store query each, both indexed.

9.3 Layout

Match the reference screenshots.

Chrome: a slim top bar — sidebar toggle at the far left, {owner}/{repo} slug next to it, tabs centred (Code, Search, Branches, Tags, and Runs when enabled), home icon at the right. The left sidebar is an off-canvas drawer holding instance name, organization-free nav (Home, your repos), the theme picker, Settings, and Log out.

Code view: two panes. Left is the file tree (Files header with a Commits toggle button on the right); right is the content pane — README rendered on the repo root, blob contents on a file, or the commit list when Commits is active. A branch/tag picker pill sits above both. Tree rows carry filetype icons; directories expand in place via htmx rather than navigating.

Commit list: flat rows — subject, then a Verified/Unverified badge, then a second line of short sha · author · date. Infinite scroll via hx-trigger="revealed" on the last row.

Commit page: title + badge, short sha, {author} committed on {datetime}. A collapsible Files Changed section with N files changed, +adds/−dels, and a Unified/Split toggle. A changed-files tree in the left sidebar that anchors to each file's diff. Each file collapses independently, shows its own +/− counts, and offers expand N hidden lines rows with up/down directional expanders.

Diff context expansion hits /-/commit/{sha}/context?file=&from=&to=&side=, which returns a rendered partial of highlighted context lines and swaps the expander row via hx-swap="outerHTML". The expander tracks the current window in data- attributes so repeated expansion walks outward. Expanding beyond the file bounds removes the expander.

Blob view: line numbers as a separate non-selectable column, anchors #L12 and ranges #L12-L20 (click, then shift-click) with the range highlighted and reflected into the URL, raw and copy buttons, and a byte/line count header. Images render inline; other binaries show a "binary file not shown" notice with a download link.

Branches: name, N ahead vs default (green), default badge, last-updated author and date — per the screenshot. Tags: name, short sha, signature badge, updated author and date.

9.4 Reactivity patterns

Interaction Mechanism
Directory expand in tree hx-get returns <ul> children, swapped innerHTML
Blob / README / commit-list pane swap hx-get + hx-target="#pane" + hx-push-url="true"
Commit list pagination hx-trigger="revealed" on the sentinel row
Diff context expansion hx-swap="outerHTML" on the expander row
Unified ↔ Split hx-get with ?view=split, re-renders the diff region, persists in a cookie
Search hx-trigger="keyup changed delay:300ms" into a results region
Branch/tag picker hx-get filtered list into a popover
Theme switch client-side attribute swap + cookie, no request

Handlers detect HX-Request and return the partial; the same handler without the header returns the full page. Write this as one helper (fn respond(page, partial)) so no route can drift.

9.5 Theming and assets

9.6 Responsive

Single breakpoint at 48rem. Below it: the file tree becomes a drawer over the content pane, the tab bar scrolls horizontally, the commit-page sidebar collapses into a dropdown, diffs force unified regardless of the toggle, and touch targets are ≥ 44px. Test at 380px wide.

9.7 Rendering markdown

comrak with GFM extensions (tables, strikethrough, task lists, autolinks, footnotes), output sanitized with ammonia. Heading anchors, relative link and image rewriting to /-/raw/{ref}/…, and highlighted fenced code blocks via crates/highlight. Raw HTML in markdown is stripped, not escaped-and-shown. README lookup order: README.md, README, readme.md, README.txt.


10. Search

Two scopes, one engine, no index for the MVP.


11. API (crates/api)

/api/v1, JSON, Bearer JWT. Purpose: automation parity with the CLI, not a public platform.

GET    /api/v1/version
GET    /api/v1/user                          # current token's user
GET    /api/v1/users                         # admin
POST   /api/v1/users                         # admin
DELETE /api/v1/users/{name}?purge=           # admin
GET    /api/v1/users/{name}/keys
POST   /api/v1/users/{name}/keys
DELETE /api/v1/users/{name}/keys/{ordinal}
GET    /api/v1/repos                         # visible to the token
POST   /api/v1/repos
GET    /api/v1/repos/{owner}/{path..}
PATCH  /api/v1/repos/{owner}/{path..}
DELETE /api/v1/repos/{owner}/{path..}?purge=
GET    /api/v1/repos/{owner}/{path..}/branches
GET    /api/v1/repos/{owner}/{path..}/tags
GET    /api/v1/repos/{owner}/{path..}/commits?ref=&page=
GET    /api/v1/repos/{owner}/{path..}/commits/{sha}
GET    /api/v1/repos/{owner}/{path..}/tree/{ref}/{p..}
GET    /api/v1/repos/{owner}/{path..}/raw/{ref}/{p..}
GET    /api/v1/groups/{owner}
POST   /api/v1/groups
DELETE /api/v1/groups/{owner}/{path..}
GET    /api/v1/search?q=&scope=

Conventions: snake_case fields; errors as {"error": {"code": "not_found", "message": "…", "details": {}}} with correct status codes; cursor pagination via ?page=&per_page= (max 100) plus X-Total-Count; 404 (not 403) for unauthorized private resources; all timestamps RFC 3339 strings on the wire even though they are epoch-ms at rest.


12. CLI (crates/cli)

clap derive, colour-aware, --config, --log-level, --json (machine-readable output for every list command). All commands operate directly on the store and filesystem — no IPC with a running server is required, since SQLite WAL and Postgres both tolerate concurrent writers. Mutations that a live server caches (repo rename, delete, user disable) bump a cache_epoch row that serve polls every few seconds.

fabrica serve [--detach] [--no-migrate]

fabrica user add <name> <email> [--pass <password>] [--admin] [--display-name <s>]
fabrica user del <name> [--purge]
fabrica user list
fabrica user passwd <name> [--pass <password>]
fabrica user disable <name> | enable <name>

fabrica key add --type <ssh|gpg> <user> <public-key|@path|->
fabrica key del <user> <index>
fabrica key list <user>

fabrica repo add <user> <name> [--private|--public] [--description <s>] [--default-branch <b>]
fabrica repo del <user> <name> [--purge]
fabrica repo list [--user <name>]
fabrica repo rename <user> <name> <new-name>
fabrica repo path <user> <name>

fabrica group add <user> <path>
fabrica group del <user> <path>
fabrica group list <user>

fabrica token add <user> <name> [--scopes <list>] [--expires <duration>]
fabrica token del <user> <index>
fabrica token list <user>

fabrica theme list
fabrica config check | show
fabrica migrate
fabrica doctor
fabrica gc [--trash-older-than <duration>]
fabrica hook post-receive          # internal, hidden from help

Behaviour notes:


13. Deployment artifacts

Dockerfile

Multi-stage, no Nix in the image:

compose.yml

Two services: fabrica (image or build: .), and postgres behind a postgres profile so the default path is pure SQLite. Named volumes for data and repos. Config supplied by bind-mounting fabrica.toml and overriding a couple of keys via FABRICA__* env vars, to demonstrate both mechanisms. Map SSH as 2222:2222 with a comment explaining ssh.clone_port. Include a commented reverse-proxy block noting that the proxy MUST NOT buffer request or response bodies on /git-upload-pack (proxy_buffering off, proxy_request_buffering off) or clones will stall.


14. Testing

Every crate carries unit tests; no functionality ships untested. cargo test alone must be sufficient (no external services required by default).


15. Working conventions

15.1 Quality gate

After every unit of work, all four must pass — no exceptions, no deferrals:

cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace

nix flake check is the superset and must pass before any phase is called done. Add a justfile with just check running all four, and use it.

15.2 Commits

Conventional Commits, one logical change per commit, committed as work happens rather than batched at the end.

Examples:

feat(git): resolve gitattributes from tree blobs
feat(ssh): accept git-upload-pack over exec channels
fix(highlight): reopen capture stack across line breaks
build(nix): wrap the binary with git on PATH
test(auth): enumerate the access matrix

15.3 Error handling

thiserror for library errors, one enum per crate; anyhow only at the binary boundary (main.rs, CLI commands). tracing for logging with structured fields; a request-id span on every HTTP request and a session span on every SSH connection. User-facing error pages are templated, themed, and never expose internals — the detail goes to the log with the request id, and the page shows the id.

15.4 Docs

docs/decisions.md is a running log: date, decision, alternatives, rationale. Seed it with the decisions this spec already makes — pack transport via subprocess, id-based repo directories, /-/ route separator, epoch-ms timestamps, serve with no stop, linear search. Update it whenever you deviate from this spec.


16. Implementation phases

Each phase ends with the full gate green and its own commits. Do not start a phase before the previous one is clean.

  1. Scaffold — flake (fenix + crane), workspace with empty crates, rust-toolchain.toml, lints, justfile, MPL-2.0 LICENSE and per-file headers, CI-less nix flake check passing. build: initialize workspace and nix flake
  2. config — TOML + env loading, validation, config check/show, example file, docs.
  3. store — schema, both migration sets, Store trait, both backends, name validation, tests.
  4. auth — argon2id, sessions, JWT, access(), the full permission matrix test.
  5. git reads — open/init bare, refs, trees, blobs, log, diff, context expansion, branch ahead/behind, fixture harness.
  6. highlight — inkjet integration, line-aware output, language resolution, attributes resolver, snapshot tests.
  7. git signatures — SSH and GPG verification against registered keys, all four states.
  8. cli core — clap tree, user/key/repo/group/token commands against the store, doctor, migrate.
  9. mail — lettre, templates, invite tokens, stdout backend, wire into user add.
  10. web shell — axum, maud layout, top bar, drawer, theming pipeline, asset overrides, embedded + disk themes, login/logout/invite, error pages, /healthz.
  11. web repo views — repo home + README, tree, blob with anchors, raw, commit list with badges, branches, tags. htmx partials throughout.
  12. web commit view — file diffs, changed-files tree, per-file collapse, unified and split, highlighted diff rows, context expansion endpoint.
  13. pack transportgit discovery, subprocess wrapper, smart HTTP read routes, the deliberate 403 on HTTP push, hooks installed at init, hook post-receive.
  14. ssh — russh server, host key generation, publickey auth against keys, exec dispatch to pack processes, permission enforcement, banner for shell attempts.
  15. search — in-repo, global, qualifier parser, budgets and partial-result reporting.
  16. api/api/v1 surface, JWT middleware, scopes, error shape.
  17. deployment — Dockerfile, compose.yml, packages.container, NixOS module, deployment docs.
  18. polish — mobile pass at 380px, caching (moka where measured), graceful shutdown, accessibility (focus rings, skip link, ARIA on the drawer and tabs, keyboard nav), a second theme proving docs/theming.md is complete.

Then, and only then, the CI: HCL config parsed with hcl-rs, Docker execution via bollard, stages and jobs, log streaming into the reserved runs view.


17. Definition of done for the MVP


18. Collaboration: issues, pull requests, labels

Issues and pull requests are enabled per repository (repos.issues_enabled, repos.pulls_enabled, both default true). When disabled, the nav tab and routes for that feature 404 for the repo. Access follows the existing auth::access verdict: any viewer with Read may open issues and PRs and comment; Write (or the author) may edit/close; Admin may manage labels and force-merge.

18.1 Scoped labels

Labels are per-repo, GitHub-style, and may be scoped: a label named type: backend has scope type and value backend. Scoped labels are mutually exclusive within their scope on a single issue/PR (assigning type: frontend removes type: backend). Unscoped labels (bug, good first issue) stack freely. A label carries a name, an optional description, and a colour (#rrggbb); the scope is parsed from the first ": " in the name.

Schema: labels (id, repo_id, name, name_lower, color, description, created_at) with a unique index on (repo_id, name_lower); join table issue_labels (issue_id, label_id).

18.2 Issues

An issue belongs to a repo, has an author, a monotonically-per-repo number (#N), a title, a markdown body, an open/closed state, optional assignee, labels, and threaded comments (markdown). Numbers are allocated from a per-repo counter (repos.next_iid bumped in the same transaction) shared with pull requests, so #N is unique across both.

Schema: issues (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, created_at, updated_at, closed_at); comments (id, issue_id, author_id, body, created_at, updated_at). Pull requests are rows in issues with is_pull = 1 plus a pull_requests side table (see below), so numbering, labels, and comments are shared.

Routes: /-/issues, /-/issues/new, /-/issues/{n}; POST endpoints for create, comment, label toggle, assignee, and state change (all CSRF-guarded, gated on Write/author).

18.3 Pull requests and merge machinery

A pull request compares a head ref (branch) against a base ref in the same repo (cross-repo forks are a non-goal). It shows the commit list and the combined diff (reusing the diff renderer), supports review comments, and can be merged server-side by a Write+ collaborator when mergeable, via one of three strategies: merge commit, squash, or rebase. Merge is performed with the spawned git subprocess (git merge/git merge --squash/git rebase into a work area, then update the base ref), never libgit2 — consistent with §3.1. A PR is auto-closed when its head is merged; conflicts block the merge with a clear message.

Schema: pull_requests (issue_id, base_ref, head_ref, merged_at, merged_by, merge_sha, merge_strategy).

Routes: /-/pulls, /-/pulls/new?base=…&head=…, /-/pulls/{n} (with /commits, /files sub-views); POST for create, comment, review, and merge.

19. Accounts, self-registration, and settings

19.1 Self-registration

Optional, off by default, controlled by instance.allow_registration (bool). When on, /register offers a sign-up form (username, email, password); on success the account is created directly (no invite) and signed in. An optional captcha guards the form: captcha.provider (none | hcaptcha | recaptcha), captcha.site_key, captcha.secret_key; when a provider is set, the widget is rendered and the token is server-verified against the provider before the account is created. Admin invites remain available regardless.

19.2 Account settings

/settings expands into sections: Profile (display name, pronouns, bio, location, links, avatar — already built); Account (change username, email, password — password change re-verifies the current password and invalidates other sessions); SSH & GPG keys (list/add/remove, mirroring the CLI key commands); API tokens (list/create/revoke, using the existing api_tokens table; the JWT is shown once on creation). Every mutation is CSRF-guarded and self-service (a user edits only their own account).