How we built a git server in Go (without reimplementing git)
The architecture of denji, the git engine behind Rezee - smart HTTP and SSH transports in ~2,000 lines of Go, one dependency, and the git binary doing what it does best.
Mar 12, 2026 · 6 min read · Kash Gohil
Rezee is one workspace for everything a team ships - and under its Code & Ship layer sits a git server we wrote in Go, called denji. It serves clones and pushes over smart HTTP and SSH, stores bare repositories on disk, and tells the rest of the system when anything changes. This post is about the architecture, and about the one decision that shaped everything else: we didn't reimplement git.
Why not reimplement the git protocol?
The temptation is real. Git's wire protocol is documented, packfiles are documented, and libraries like go-git exist. But a git server has exactly one job that matters: behave identically to git. Every line of protocol code we write is a line that can disagree with the git binary on somebody's laptop - across protocol versions, capability negotiations, and twenty years of edge cases.
So denji's core design rule is: never parse git's wire protocol. Every transport handler is a thin pipe that spawns the right git subprocess and connects it to the client. The whole service has a single non-stdlib dependency, golang.org/x/crypto for SSH. (The SSH server gets its own deep-dive - delegated key auth where denji stores no keys, and the one flag that differs from the HTTP path.) Git itself does upload-pack, receive-pack, and every on-disk detail - because git is the only program that will always agree with git.
How does the smart HTTP transport work?
Git's smart HTTP protocol needs three endpoints per repository:
GET /{workspace}/{repo}/info/refs?service=...- ref advertisement (discovery)POST /{workspace}/{repo}/git-upload-pack- clone and fetchPOST /{workspace}/{repo}/git-receive-pack- push
The discovery response is the only place denji writes wire format itself: a pkt-line service banner, then a flush packet, then git takes over with --advertise-refs:
w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
w.Header().Set("Cache-Control", "no-cache")
if err := pktline.WriteString(w, "# service="+service+"\n"); err != nil {
return // client went away
}
if err := pktline.WriteFlush(w); err != nil {
return
}
cmd := exec.Command("git", subcommand, "--stateless-rpc", "--advertise-refs", dir)
cmd.Stdout = w
cmd.Stderr = os.StderrThe pkt-line format itself is charmingly small: a 4-digit hex length prefix that includes its own four bytes, and 0000 as a flush. Our entire pkt-line "implementation" is write-only and two functions long:
func encode(payload string) string {
if len(payload) > maxPayload {
panic("pktline: payload too large")
}
// %04x = lowercase hex, zero-padded to (at least) 4 digits.
return fmt.Sprintf("%04x%s", len(payload)+4, payload)
}The POST handlers are pipes. --stateless-rpc puts git into one-request-one-response mode designed exactly for HTTP; the request body goes to git's stdin, git's stdout goes to the response. Two details make it feel right: gzip request bodies are transparently decompressed (git compresses pushes), and the response writer flushes after every write so clone progress streams live instead of arriving in one lump at the end:
cmd := exec.Command("git", subcommand, "--stateless-rpc", dir)
cmd.Stdin = body
cmd.Stdout = newFlushWriter(w) // flush so clone/push progress streams
cmd.Stderr = os.StderrBecause we never touch the negotiation, protocol versioning is git's problem: whatever versions the server-side binary speaks, clients get. The dumb HTTP protocol is simply rejected.
How does SSH auth work without storing keys?
The SSH side uses golang.org/x/crypto/ssh with public-key auth only. Here's the interesting part: denji doesn't store keys, tokens, or users at all. Identity lives in makima, Rezee's API service, and denji asks it over an internal API on every handshake:
func (s *Server) publicKeyCallback(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := ssh.FingerprintSHA256(key)
userID, err := s.sshAuth(fp)
if err != nil {
return nil, fmt.Errorf("unknown key")
}
return &ssh.Permissions{Extensions: map[string]string{"user-id": userID}}, nil
}The key's SHA-256 fingerprint goes to makima; a user ID comes back and rides along in the connection's permissions. When the client then requests git-upload-pack or git-receive-pack, denji asks makima a second question - is this user allowed to read or write this repo - before spawning anything.
HTTP auth follows the same philosophy, even more radically: denji forwards the client's Authorization header to makima verbatim and never decodes the token itself. One service owns authorization decisions; the git engine just enforces answers. The host key is an Ed25519 key generated on first boot and persisted with 0600 permissions.
How are repositories stored?
Bare repos on disk, two levels deep: <data-dir>/<workspace>/<repo>.git. Creation is git init --bare - git is the source of truth for on-disk layout, so we don't hand-roll it.
The security boundary is a single regex. Both path segments must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ - no slashes, no leading dots, no .., validated before any path is joined. It's boring, and boring is what you want between an HTTP path and your filesystem.
Reading repos for the UI - branches, commit history, trees, blobs, blame, diffs - is also all subprocess plumbing (for-each-ref, ls-tree, cat-file, blame --porcelain). Two hardening details we'd recommend to anyone shelling out to git: user-supplied refs and paths may never start with -, and --end-of-options goes before user values, so nobody turns a branch name into a flag. (We later attacked our own read layer with a real --output= file-write exploit to prove those two guards each hold the line alone - and found the one command, rev-parse, where only the first one works.) For log parsing, fields are separated with %x1f and records with %x1e - control characters that can't appear in commit subjects - so a mischievous commit message can't break the parser. Even merges happen without a working tree: merge-tree --write-tree, then commit-tree, then update-ref - a full merge in a bare repo, no checkout anywhere.
How does a push reach the rest of Rezee?
Through git's own extension point: a post-receive hook, installed into every repo at creation. It reads the pushed refs from stdin and notifies makima - which is what updates PRs, closes issues, and triggers CI pipelines:
const postReceiveHook = `#!/bin/sh
while IFS=' ' read -r old new ref; do
if [ -n "$REZE_MAKIMA_URL" ]; then
curl -sf -X POST "$REZE_MAKIMA_URL/internal/push-event" \
-H "Content-Type: application/json" \
-H "X-Internal-Secret: $REZE_INTERNAL_SECRET" \
-d "{\"repo\":\"$REZE_REPO\",\"pusher\":\"$REZE_PUSHER\",\"ref\":\"$ref\",\"sha\":\"$new\"}" || true
fi
done
`The hook's context - which repo, which user - arrives as environment variables injected by the receive-pack handler at push time. The || true is load-bearing: a webhook hiccup must never fail someone's push. Notification is best-effort; the push itself is sacred.
What's next?
Being honest about the edges: git subprocesses currently run without per-operation timeouts, and the push-event environment is injected on the HTTP path but not yet the SSH one - both on the list. The broader lesson stands, though. By refusing to reimplement git, the entire server - two transports, auth, storage, metadata, and system events - stays around two thousand lines of Go that mostly just move bytes between sockets and subprocesses. Small enough to read in an afternoon, and every protocol edge case is handled by the one implementation guaranteed to agree with your client: git itself.
FAQ
Why not use go-git instead of shelling out?
go-git is a fine library, but a server's correctness bar is "indistinguishable from git" across every client version and protocol quirk. Subprocessing the real binary makes that guarantee free, keeps dependencies to one, and costs a process spawn per operation - cheap next to the network transfer it fronts.
Does denji support the dumb HTTP protocol?
No. Requests without a recognized service parameter are rejected. Every modern git client speaks smart HTTP, and supporting the dumb protocol would mean serving loose objects and packfiles directly - exactly the kind of surface we built denji to avoid.
Which git protocol version does it support?
Whichever the installed git binary speaks - denji never parses the negotiation, so protocol v0 and v2 both work transparently. Upgrading protocol support is apk upgrade git, not a rewrite.