git-forge
A small self-hosted git forge: HTTP push and pull, cgit browsing with syntax highlighting, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. One binary, one data directory, one listener.
git-forge does not reimplement git. It runs git-http-backend and cgit as
CGI behind its own listener, authorises every request before exec'ing either,
and supplies only what those two lack.
Out, deliberately: pull requests, patch review, CI, hooks, forks, repository rename, inbound mail, SSH, attachments, milestones, assignees, and closing an issue from a commit message. Also out: JavaScript, a database, a reverse proxy, and a process supervisor.
Limits: one process per datadir, git grep is the issue search, and a few
thousand issues per repository is the ceiling. One push is bounded at 1 GiB
of code and 16 MiB of wiki; the total is not. Eight cgit and eight
git-http-backend children run at once and the rest queue.
Build
Go 1.25 or later: go build
CGO_ENABLED=0 makes the binary static, which is what Containerfile does.
Gating checks: gofmt -l . must print nothing go vet ./... must be clean go mod tidy -diff must print nothing go test ./... unit and integration tests, needs git; some skip without cgit ./test builds the image and drives a container end to end
./test needs docker, git, curl, lynx, openssl, python3, and timeout. Checked
by hand: OIDC against a real provider, and Mailgun against a real domain.
Running outside the container needs git, cgit, and highlight. On Alpine
git-http-backend is in the git-daemon package; without it every clone and
push answers 500. GIT_FORGE_CGIT names cgit.cgi when it is not at
/usr/share/webapps/cgit/cgit.cgi.
Run
./git-forge -datadir /var/lib/git-forge
The datadir comes from the flag, else GIT_FORGE_DATADIR, else
/var/lib/git-forge, and never from the config. Everything missing is
created on first start.
There is no TLS. A push token is sent as Basic auth on every request, and the
session cookie is Secure only when base_url is https. Keep the listener on
localhost, or put something in front that terminates TLS and set base_url to
the origin it serves.
Administration is web only. With no accounts, git-forge prints a one time
setup token to stdout and serves /setup; that form takes the token and
creates the first admin. A restart before setup prints a new token, and once
any account exists /setup answers 404.
Bare metal on Alpine: adduser -D -H forge, install the binary and
git-forge.initd as /etc/init.d/git-forge, and put secrets as VAR=value
lines in /etc/conf.d/git-forge, mode 0600. The unit creates the datadir,
runs as forge, and writes stdout to /var/log/git-forge.log.
Configuration
{datadir}/config/forge.json, plus environment for secrets. The config has no
field for a secret:
GIT_FORGE_COOKIE_KEY else generated into {datadir}/cookie.key
GIT_FORGE_MAIL_MAILGUN_API_KEY
GIT_FORGE_OIDC__CLIENT_SECRET
Five fields have an environment override: GIT_FORGE_LISTEN,
GIT_FORGE_SITE_NAME, GIT_FORGE_SITE_DESC, GIT_FORGE_BASE_URL, and
GIT_FORGE_CLIENT_IP_HEADER. An override is an overlay: /admin rewrites
the file without it.
site_name, site_desc, logo, and icon are editable at /admin, which
commits the change. logo and icon are image paths, relative to the config
directory, served at /logo and /favicon.ico; empty means cgit's own.
mail.driver is one of:
none the default. Nothing is sent; the log names each dropped
message by recipient and subject, never its body
log whole messages to the log, for development. A set-password
or reset link is a capability, and this driver logs it
mailgun one form-encoded POST to mail.mailgun.base_url, with the
key from the environment
An OIDC provider takes an optional allow: addresses, or @domain for a
whole domain. An empty list admits anyone the provider vouches for. It is
checked at every login, so removing an entry revokes:
"allow": ["carol@example.com", "@example.com"]
client_ip_header is empty by default. Behind a proxy every request shares
one address, so every per address rate limit becomes one bucket; naming the
header the proxy sets is the way out. Naming one trusts it: the proxy must
overwrite it, and nothing may reach the listener around the proxy.
"client_ip_header": "CF-Connecting-IP"
Rate limits are constants in the source. Startup validates forge.json and
repos.json and exits nonzero on anything incoherent.
Data directory
config/ git repo with a working tree: forge.json, users.json,
repos.json, and an uploaded logo and icon
cookie.key generated secret, 0600, outside the config repo
mailq/ spooled outbound mail, transient
repos/ bare git repos, holding nothing git-forge wrote
Back up with tar against a stopped service, or a filesystem snapshot. The
datadir is credential material in full: users.json holds argon2id hashes,
TOTP secrets, and recovery codes, and the config repo keeps their history.
Exactly one process may serve a datadir: git-forge takes an exclusive flock
on it and a second process refuses to start. Storage must be a real
filesystem with atomic rename() and working lock files; FUSE-mounted object
storage is not.
Debugging
One log line per request on stdout. Query strings are not logged, because reset links and OIDC codes arrive in them, and the path is quoted, so a newline in one cannot write its own line.
The config repo is the audit trail: cat {datadir}/config/repos.json git -C {datadir}/config log -p repos.json when did bob get write git -C {datadir}/config log --author=alice what has this admin done git -C {datadir}/config revert HEAD undo a fat-fingered grant
The working tree is the source of truth; a commit that fails is logged and the request still succeeds.
Issues and wiki pages are paths inside a git tree: git -C {datadir}/repos/infra/dns.issues.git show master:issues/7.json
Repository state is derived from the tree: a directory ending in .git is a
repository, anything else is a section one level deep. A directory with no
entry in repos.json is served private with issues and wiki off, and shown
with an adopt button at /admin; an entry with no directory is ignored. Both
are logged at startup.
A repository path is one or two components of lowercase letters, digits,
dot, dash, and underscore. Neither component may be a name one of git-forge's
own routes owns, and a second component may not be a cgit route either:
/infra/log is the log of the repository infra.
Every form refused with a 403 while everything else works means base_url is
not the origin the browser uses: a form post whose Origin disagrees with it
is refused.
An image that does not load is one that is not this origin's. The security policy refuses remote images, so a badge in a README cannot tell a third party who read it. No script runs anywhere.
Handing out access asks for the acting account's own authenticator code, at
/admin and on a repository's settings alike, since what it hands out
outlives the session. A code works once: a second action within the same
thirty seconds waits for the next code. An admin without an authenticator is
sent to enrol one. Changing the second factor asks for the password on a local
account and the enrolled code on an external one.
cgit is invoked per request with a generated config naming only the one repository. Those configs live in a temporary directory logged at startup and are not part of the backup.
git gc --auto runs after a push and after every issue and wiki write, so a
repository's on-disk shape changes when git's thresholds fire.
Tokens
Git over HTTP takes a token, never a password: git cannot be asked for a second factor, and a token is separately scoped and separately revoked.
A token is a credential, not a permission. Minting one at /settings asks
only that the account can reach the repository; what it may do is decided on
arrival, from the same grants the web uses.
An anonymous git request for anything it may not read is answered 401, whether the repository exists or not, so the answer never says which private names are taken. An authenticated request without read answers 404, as the web does.
Container
podman build -t git-forge .
podman run -p 8080:8080 -v git-forge:/var/lib/git-forge git-forge
Alpine, non-root, volume at /var/lib/git-forge, tini as PID 1 to reap
git's detached gc. /healthz answers 200 and SIGTERM drains and exits.
License
BSD 3-Clause, in LICENSE. cgit's stylesheet is served from the cgit package
at runtime rather than copied into this tree: it is cgit's, under cgit's own
licence.