17.4 KiB · text 5af55d9

GitGud !?

A self-hosted code forge built on Phoenix 1.8 — git over HTTPS + SSH, issues, pull requests, wikis, ForgeFed federation, CI via the Forgejo Actions runner protocol, OCI/LFS storage. Designed to embed the right external tools (Forgejo runner, Garage, zot) rather than reinvent them.

Status: development. The forge is feature-complete enough to host its own development and federate with other ForgeFed-speaking instances, but it has not been hardened for public-facing production use. Stand it up on private infrastructure, evaluate the threat model for your situation, and contribute patches if anything’s off.

What’s in the box

  • Git server
    • Smart-HTTP at /<owner>/<name>.git/* (anonymous read on public repos, basic-auth elsewhere)
    • Embedded SSH daemon with DB-backed key auth (no system git user, no authorized_keys file)
    • Server-side post-receive hook fans out to commit-backfill workers
    • Git LFS (Batch API) over pluggable object storage
  • Repo browsing
    • Tree, blob, commit, log views with deep linking and a shared repo header on every subpage (title + Issues/Labels/Forks/Compare/Packages/Actions/Settings strip + Fork button)
    • Clone-URL copy widget on the repo show page — three-state expanding pill (HTTP/SSH toggle → copy button → inline URL text), one-click clipboard with execCommand fallback
    • Line-level diff renderer with Lumis-highlighted hunks on PR show, commit show, and compare; parsed diffs cached per (base, head). Lazy per-file expansion — large diffs keep hunks out of the DOM until you open the file
    • Syntax highlighting via Lumis (tree-sitter, Rust NIF) — cached per-blob
    • Full GFM markdown with cross-references (#42, #x2a hex, owner/repo#42), GitHub-style alerts, footnotes, task lists, fenced-code syntax highlighting — cached per-render
    • Language stats: 0.3em GitHub-style bar on the repo page driven by a per-commit Oban job; click for /stats/languages (full table) and /stats/committers (commits + churn per author)
    • Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc) — separate from the daisyUI page theme
  • Collaboration
    • Issues + comments with Write/Preview tabs, label picker, query-string autofill
    • Quote-reply button on every comment — appends > -prefixed source with attribution
    • Pull requests with mergeability probe, 3-way merge, cross-repo (fork) PRs, line-level diff inline
    • Forks + fork-sync (fast-forward CAS) + compare-any-two-refs view
    • Branch protection, deploy keys, webhooks, wikis
    • Labels with HTML color picker + 16-swatch palette + live preview; org-level labels inherited by every repo unless the repo opts out (and re-opts in via a hidden-labels expander)
  • Profiles
    • User + org profile pages at /u/:handle and /orgs/:handle. Display name, bio, url, location, company, public email, timezone, socials. Two-column layout with bio on the left, pinned repos + README on the right.
    • Theme-aware identicon avatars — deterministic 5×5 mirrored grid, fills reference daisyUI CSS variables so swapping themes recolors every avatar live. No upload pipeline yet; the identicon is the avatar.
    • Pinned repos — drag-to-reorder grid, up to 7 per user / per org, includes repos from orgs you’re a member of. Removed-from-org? Your personal pins of that org’s repos clean themselves up.
    • Auto-created same-name profile README — registering alice provisions alice/alice with a seeded “Hi, I’m @alice 👋 …” README.md on the default branch. Edit it like any other repo; the rendered output shows on the profile.
  • Account + org governance
    • Registration modes: :open / :invite_only (default) / :closed, configurable per-instance
    • Invites — single-use tokens with create / list / revoke per user; UI at /users/settings/invites
    • Org visibility: public / internal / private. Repos can’t be more open than their org; tightening an org cascades to over-public repos in one transaction. Public orgs are accessible to anonymous viewers; private orgs require membership.
    • Org members management at /orgs/:handle/settings/members — add by handle or email, change roles, remove, with a last-admin guard so an org can never end up admin-less
  • Auth & MFA
    • Magic-link login + email/password
    • TOTP (any RFC 6238 authenticator) with QR enrollment + 10 single-use recovery codes
    • WebAuthn / FIDO2 — hardware security keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), browser passkeys
    • Recovery codes shared across factors; instance-level enforcement with N-day grace period
  • Federation (ForgeFed / ActivityPub)
    • HTTP Signatures on inbound + outbound deliveries; outbound dead-letter after 8 retries
    • Push / ticket / merge / comment activities outbound; per-task hourly key refresh of stale remote actors
    • Inbound: Follow, Offer (federated PR), Update, Reject, Create(Note) comments, Like/Dislike as PR reviews
    • Outbound Offer from the PR new page; outbound Like/Dislike on local reviews of federated PRs
    • Per-instance moderation: allowlist mode (default), per-instance + per-actor blocks, interaction policies (public / followers / approved)
    • Actionable reports queue in admin: inline previews, replace-with-mod-message (reversible — admins + the original author can <details>-toggle the original), suspend remote actors, forward to source instance via AP Flag. Re-reporting moderated content is gated at the API layer.
    • mix gitgud.federation.suggest_blocks — peer block lists become a review queue, not enforcement (cascades are deliberately declined)
  • CI
    • Speaks the Forgejo Actions runner protocol (Twirp) — point any modern forgejo-runner at it; protocol path autodetected (ping.v1, runner.v1, with/without _apis/)
    • Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
    • Five deployment styles offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable register + daemon snippets per style
    • Re-run / cancel buttons on the workflow show page
    • Per-task HS256 JWT minted on FetchTask, gates artifact + cache endpoints
    • GitHub Actions cache server v1 with hit matching (primary exact → fallback prefix, current ref → default ref) + age/size eviction worker
    • GitHub Actions artifacts API v4
    • Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
  • Storage
    • Pluggable GitGud.Storage behaviour
    • GitGud.Storage.Disk (default) — single-node dev, single-node prod, NFS-shared multi-node
    • GitGud.Storage.Garage — S3-flavored (Garage)
    • Add another with one @behaviour impl
  • Packages
    • OCI registry hosted in zot — deployment manifest ships in zot.yml (PVC-backed by default, S3/Garage driver kept in a comment block)
    • The forge owns /v2/token: docker login → Basic-auth handshake → RS256-signed JWT scoped to repository:<owner>/<name>:pull,push (Packages.Token); zot validates with the matching public PEM
    • Keypair minted by mix gitgud.registry.keygen (PKCS#1 private, SubjectPublicKeyInfo public — verified against openssl rsa -pubout); prints kubectl one-liners for splitting it across the elixir-args and zot Secrets
    • Local mirror of (package, version) rows via the webhook at /_webhooks/zot (Bearer-token guarded by ZOT_WEBHOOK_SECRET)
    • Browseable at /r/<owner>/<name>/packages with per-package version history

Architecture

┌───────────────────────────────────────────┐
│ Phoenix endpoint (web) │
git+ssh ───► │ • smart-HTTP plug • LFS plug │
git+https ─► │ • LiveView UI • AP inbox+outbox │
│ • runner Twirp API • storage controller │
└───┬────────────┬───────────┬─────────────┘
│ │ │
┌───────▼──────┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Postgres │ │ Oban │ │ GitGud.Cache │
│ + git_refs/ │ │ jobs │ │ (Nebulex/ETS) │
│ git_commits │ │ │ │ │
│ cache │ └─────────┘ └─────────────────┘
└──────────────┘
┌───────▼──────┐ ┌────────────────┐
│ gix (NIF) │ ┌──────► │ GitGud.Storage │
│ + git CLI │ │ │ Disk / Garage │
│ fallback │ │ │ (LFS+CI blobs) │
└──────────────┘ │ └────────────────┘
│ │
┌───────▼─────────────┴──────────────────────────┐
│ priv/repos/<owner>/<name>.git │
│ priv/storage/<bucket>/<key> │
└────────────────────────────────────────────────┘

Embed, don’t reinvent. The stance is to delegate to mature external tools where they exist:

  • forgejo-runner for CI execution (we just speak its protocol)
  • Garage (or any S3) for production object storage
  • zot for the OCI registry — deployed alongside via zot.yml; the forge owns /v2/token auth and a Postgres mirror of registry metadata, zot owns the manifests + blobs
  • gix (Rust) for git operations via a Rustler NIF, with a git CLI fallback for anything not yet wired through the NIF

Quick start (development)

# Postgres expected on localhost:54328 (see config/dev.exs)
mix setup # deps, db, migrations, esbuild + tailwind install
iex -S mix phx.server

Open http://localhost:4000 and register. The embedded SSH daemon listens on port 2222 — once you’ve added a key under /users/settings/ssh-keys, you can:

git remote add origin ssh://<handle>@localhost:2222/<owner>/<repo>.git
git push -u origin master

<handle> is the local-part of your registered email (Gerrit-style identity carrier — the only way the SSH channel handler can identify you between is_auth_key and the exec callback).

Configuration knobs

These all live in config/*.exs. Key ones:

Knob What Default
GitGud.Storage impl Object storage backend GitGud.Storage.Disk
GitGud.Storage.Disk root Where Disk writes blobs priv/storage
GitGud.Ssh.Server enabled Embedded SSH daemon false (dev: true)
GitGud.Ssh.Server port SSH daemon port 2222
:registration_mode :open / :invite_only / :closed :invite_only
GitGud.Federation federation_mode :allowlist or :open :allowlist
GitGud.Cache Nebulex cache sizing 50k entries / 200MB
GitGud.Accounts.TwoFactor required / grace_period_days Instance-wide MFA enforcement + grace window false / 14
GitGud.Workflows.Workers.CacheEviction max_age_days / per_repo_max_mb actions/cache pruning knobs 90 / 500 / dry_run: false
GitGudWeb.PackagesWebhookController shared_secret Bearer-token gate on the zot webhook (env: ZOT_WEBHOOK_SECRET) nil (open in dev)
GitGud.Packages.Token private_key_pem RS256 PEM that signs OCI registry Bearer tokens (env: GITGUD_REGISTRY_PRIVATE_KEY) nil (registry auth disabled)
GitGud.Packages.Token issuer / audience / key_id / ttl_seconds JWT claim defaults zot must agree on gitgud-registry / container_registry / gitgud-registry-1 / 300

To swap to S3-flavored storage in prod:

config :git_gud, GitGud.Storage, impl: GitGud.Storage.Garage
config :ex_aws, :s3, scheme: "http://", host: "garage.internal", port: 3900, region: "garage"

To open the federation to the whole fediverse rather than an allowlist:

config :git_gud, GitGud.Federation, federation_mode: :open

Storage backends

GitGud.Storage is a four-method behaviour (put / get / delete / presign_url). Two implementations ship:

  • Disk — filesystem-backed. Presigned URLs route through a local StorageController that verifies a Phoenix.Token. Works for single-node dev, single-node prod, and multi-node prod over a shared NFS mount. No additional services required.
  • Garage — S3 via ExAws.S3. Real presigned URLs. Use for any multi-node prod that doesn’t want to share a filesystem.

Adding a third (Backblaze, R2, local minio) is one @behaviour impl.

Federation

GitGud speaks ForgeFed over ActivityPub with HTTP Signatures (draft-cavage-12). It is allowlist-mode by default — set federation_mode: :open to accept deliveries from arbitrary instances. The federation admin LiveView at /admin/federation exposes:

  • recent deliveries (filterable by status)
  • quarantine queue for policy_approved interactions
  • instance + actor block CRUD
  • allowlist CRUD with mode toggle
  • open report queue with dismiss / action / forward-to-source actions

Federated PRs (inbound Offer) materialize as local PR rows with source_actor_id pointing at the remote actor and no author_id. Update / Reject / comment-via-Create(Note) follow the same shape.

Outbound Offer (local PR against a remote repo) is available from the PR new page via the “Send to a remote repository” toggle — enter the target repo’s AP actor URL.

What’s declined by design:

  • Defederation cascades (auto-blocking based on peer block lists) — becomes a coordinated-harassment vector; ForgeFed lacks signed block-list semantics
  • Automated content classification (ML) — drift, bias, opaque appeals; use as a signal feeding admin queue at most

Caching

GitGud.Cache is Nebulex (local ETS adapter) started under the app supervisor. Used for:

  • syntax-highlighted source blobs (key: sha256(content) + lang + theme)
  • rendered markdown (key: sha256(body) + theme)

Keys are content-hash based, so entries are reusable across repos and refs and never go stale. To partition or replicate across a cluster, swap the adapter — call sites don’t change.

CI / Forgejo runner

Point a forgejo-runner at /api/actions/_apis/runner.v1.*. Register via RunnerService/Register; the runner is then assignable to a repo / org pool. On each job poll we mint an HS256 JWT per task that gates the artifact + cache endpoints under /api/actions/_apis/....

The registration UI (/r/<owner>/<name>/settings/runners or /orgs/<handle>/settings/runners) emits ready-to-paste snippets for Native binary, Docker, Podman, Podman Quadlet, and Kubernetes — all built around the official forgejo-runner generate-config → register → daemon --config runner-config.yml flow per the Forgejo install docs.

The runner is external. A native runner is on the roadmap but the external one works today and is well-tested by the Forgejo community.

OCI registry

When you want pushes from your workflows (docker/build-push-action, kaniko, etc.) to land in your own registry, deploy zot alongside the app:

mix gitgud.registry.keygen \
--out-priv registry-priv.pem \
--out-pub registry-pub.pem
# private half → elixir-args.gitgud_registry_private_key
# public half → zot.registry-pub.pem
# (the task prints kubectl one-liners for both)
kubectl apply -f zot.yml

Add GITGUD_REGISTRY_PRIVATE_KEY to the app pod (already wired in app.yml) and a DNS record for your registry hostname pointing at the ingress. Workflows then push to <registry-host>/<owner>/<repo>:<tag> and the auth handshake flows through the forge.

What’s not done

See ROADMAP.md. Highlights:

  • gix NIF wiring for log / commit / tree / diff_stats / merge_base / merge_tree (Cli fallback works; slice 56 fills in the hot paths)
  • In-browser PR conflict resolution
  • Avatar file uploads (the theme-aware identicon is the avatar today)
  • Native runner (low priority while the external one works)
  • Code search (Postgres FTS handles issues/PRs; code search is its own beast)
  • RFC 9421 (HTTP Messages) signatures when peers upgrade from draft-cavage-12
  • Reputation aging for new federated hosts

See PROGRESS.md for the slice-by-slice history of how the forge was built, and what each shipped — currently through slice 58.

Project conventions

See AGENTS.md for the conventions and guardrails used when working on this codebase (Phoenix 1.8 patterns, Tailwind v4 with daisyUI, LiveView routing, etc).

mix precommit runs format, compile --warnings-as-errors, and test. CI matches.

License

GitGud is Free Software under the AGPL