| .forgejo | ||
| assets | ||
| config | ||
| lib | ||
| native | ||
| priv | ||
| rel | ||
| test | ||
| .dockerignore | 1.3 KiB | |
| .formatter.exs | 225 B | |
| .gitignore | 1.3 KiB | |
| .gitlab-ci.yml | 475 B | |
| AGENTS.md | 25.6 KiB | |
| app-secrets.example.yml | 1.3 KiB | |
| compose.yml | 374 B | |
| Dockerfile | 5.1 KiB | |
| git | 0 B | |
| LICENSE.md | 33.5 KiB | |
| mix.exs | 4.5 KiB | |
| mix.lock | 27.8 KiB | |
| PROGRESS.md | 110.6 KiB | |
| README.md | 17.4 KiB | |
| ROADMAP.md | 3.0 KiB | |
| smtp-secret.example.yml | 2.9 KiB |
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
gituser, noauthorized_keysfile) - Server-side post-receive hook fans out to commit-backfill workers
- Git LFS (Batch API) over pluggable object storage
- Smart-HTTP at
- 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,#x2ahex,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/:handleand/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
aliceprovisionsalice/alicewith a seeded “Hi, I’m @alice 👋 …”README.mdon the default branch. Edit it like any other repo; the rendered output shows on the profile.
- User + org profile pages at
- 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
- Registration modes:
- 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
Offerfrom 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 APFlag. 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-runnerat 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+daemonsnippets 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)
- Speaks the Forgejo Actions runner protocol (Twirp) — point any modern
- Storage
- Pluggable
GitGud.Storagebehaviour GitGud.Storage.Disk(default) — single-node dev, single-node prod, NFS-shared multi-nodeGitGud.Storage.Garage— S3-flavored (Garage)- Add another with one
@behaviourimpl
- Pluggable
- 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 torepository:<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 againstopenssl rsa -pubout); prints kubectl one-liners for splitting it across theelixir-argsandzotSecrets - Local mirror of
(package, version)rows via the webhook at/_webhooks/zot(Bearer-token guarded byZOT_WEBHOOK_SECRET) - Browseable at
/r/<owner>/<name>/packageswith per-package version history
- OCI registry hosted in zot — deployment manifest ships in
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/tokenauth and a Postgres mirror of registry metadata, zot owns the manifests + blobs - gix (Rust) for git operations via a Rustler NIF, with a
gitCLI 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
StorageControllerthat verifies aPhoenix.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_approvedinteractions - 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