The info/refs dance, pkt-line framing, and upload-pack/receive-pack round trips — a walkthrough of implementing Git smart HTTP by hand, from the protocol bytes up.
The Protocol That Runs Under Your Clone
Every git clone https://... you've ever run executed a protocol most developers have never seen, because the tools hide it. It's called smart HTTP, and it's older, weirder, and more elegant than you'd expect. When we built our git server in Go, we implemented this protocol by hand — no framework, just the spec and the wire bytes. This post walks through how it actually works, from the first HTTP request to the final ref update.
Understanding this protocol isn't trivia. If you ever debug a git integration, write a hook server, or wonder why a clone hangs instead of failing, this map is the difference between guessing and knowing.
The Cast of Characters
Two git processes talk to each other over HTTP:
upload-pack— The server side of reading. It hands the client the refs and the objects needed for a clone or fetch.receive-pack— The server side of writing. It accepts the objects a client pushes, checks them, and updates refs.
The HTTP layer's job is to multiplex these two stateful conversations onto stateless HTTP requests. Git does this with a negotiation called discovery + service, and it's the heart of the protocol.
Step 1: Discovery (info/refs)
The client's first request is always:
HTTP
GET /org/repo.git/info/refs?service=git-upload-pack
The ?service= query parameter is the protocol's clever trick: it tells the server which conversation the client wants before any git objects move. The server responds with the smart-HTTP preamble:
HTTP
Content-Type: application/x-git-upload-pack-advertisement
001e# service=git-upload-pack\n
0000
0032<sha> refs/heads/main
0032<sha> refs/heads/develop
...
0000
Those lines are pkt-lines — the protocol's framing. Each line starts with a 4-hex-digit length (including the length itself), followed by the payload. 0000 is a flush packet that ends a section. If you've ever seen # service= or 0000 in a hex dump and wondered, this is it.
For the server implementer, discovery is where the auth decision gets its first natural home: you can reject the clone at discovery time with a
401, before a single object is transferred.
Step 2: Negotiation (The upload-pack Round Trip)
For a fetch, the client then POSTs to upload-pack with a request body that lists what it has and what it wants:
HTTP
POST /org/repo.git/git-upload-pack
Content-Type: application/x-git-upload-pack-request
0054want <sha> multi_ack_detailed side-band-64k thin-pack ofs-delta\n
0032have <sha>\n
0009done\n
0000
The server replies with the packfile it built from the refs the client asked for, minus the objects the client already has. The negotiation can be one round (for done) or several (for incremental fetches), with the server sending ACKs as it figures out the common ancestor set.
Key Implementation Details
side-band-64k— The packfile doesn't come back alone. The server multiplexes progress messages and error messages into separate channels inside the response, sogit clonecan show you "Counting objects..." while the pack downloads. Implement this wrong and clones work, but progress reporting is garbage.thin-packandofs-delta— Compression tricks the server is allowed to use when the client has offeredhaverefs. They're why a fresh clone of a big repo is smaller than the repo itself.
Step 3: The Write Path (receive-pack)
Pushing flips the direction. The client POSTs to git-receive-pack with the ref update commands first:
HTTP
POST /org/repo.git/git-receive-pack
006e<old-sha> <new-sha> refs/heads/main\0 report-status\n
0000
Then it streams the packfile of objects. The server, after receiving everything, runs the update checks — and this is where your policy hooks in. Before we touch a ref, our server:
Resolves the authenticated identity (from the earlier auth layer).
Checks user permissions to ensure write access to this repo and branch.
Runs pre-receive hooks (including the push event that triggers our pipeline).
Updates the ref under a lock.
Sends back a
report-statusresponse listing each ref update's result.
The report-status reply is how the client learns a push succeeded per-ref:
HTTP
0031ok refs/heads/main
0000
The Parts That Bite
Implementing this by hand, the pain concentrates in four places:
ChallengeWhy It's Trickypkt-line framingOne wrong byte in a length header desynchronizes the whole conversation, and the failure mode is a hang, not an error.Capability negotiationBoth sides advertise capabilities. The server must only use capabilities the client offered — use one it didn't advertise and some git clients silently fail.Large pushes & timeoutsA 2 GB push is one long HTTP request. The server needs to stream, not buffer; buffering the whole body kills memory on big repos.shallow and Protocol v2We implemented v0 first, then v2 (protocol.version=2). v2 splits discovery from capability advertisement, making it cleaner but doubling the state space.
Why It Was Worth It
Implementing the protocol by hand sounds like masochism, and honestly it was partly that. But it gave us three things we couldn't have bought:
A single auth model. Because we own the HTTP endpoints, every clone, fetch, and push goes through our identity layer — no
git-daemonback door.Precise failure semantics. When something goes wrong, we know exactly which
pkt-linewas involved, because we wrote it.The ability to test replay. Our test suite records real client traffic and replays it against the server — the same kind of corpus the real git developers use.
And practically: git's smart HTTP is well-specified. The documentation and the reference implementation together are enough to build against, if you're willing to read protocol dumps and trust your tests.
Closing Thought
Smart HTTP is the invisible machinery under billions of clones. Implementing it by hand is the best way to understand why it's built the way it is — the stateful conversations, the byte framing, the careful capability dance. And for a platform that needs git as a first-class subsystem, there's no substitute for owning that machinery.
Every MonkeysCloud project ships with 2 free servers and 2 free databases — git hosting, environments, CI/CD, and MonkeysAI included. Try it free
