What clone actually says
In part one we opened .git and found a small database made of files. In part two we followed the references that point into it, and in part three we watched git gc squeeze the database into a packfile. One promise is still pending, and it's the one that sounds most like black magic: what actually crosses the network when you run git clone?
The answer is almost disappointing: two HTTP requests. A GET and a POST. No persistent connection, no custom port, no binary handshake you couldn't read with curl. This post reads both requests byte by byte, and like the rest of the series, nothing below is a mockup. Two repositories are alive in this page: one plays the server, one plays the client, and they speak the real protocol to each other. The remote is the exact two-commit repo we built in part one, so you already know every hash it contains. By the end of the page, the client will contain them too.
01 · Two endpoints
Git over HTTP comes in two flavors. The old one, the dumb protocol, needs no server-side git at all: the client fetches .git as static files and walks the object graph itself, one request per object. That's what the objects/info folder from part one is for. When you hovered it, the annotation said "used by HTTP communication": git update-server-info writes objects/info/packs so that dumb clients can at least discover which packfiles exist. It's slow, it can't negotiate anything, and nobody serious serves it anymore.
The smart protocol is what GitHub, GitLab and every modern host speak. There's a real program on the other side, git-upload-pack, and the whole conversation with it fits in two round trips, specified here:
That's the entire surface. The first request asks "who are you and what do you have?", the second one says "then I want this" and gets everything back. Fetching uses git-upload-pack (from your point of view the server uploads a pack), and pushing talks to its mirror image, git-receive-pack. We'll only need the first one today: clone is a fetch with an empty starting point.
The byte counts above are computed live from the exchange running in this page. Not bad for a repository with two commits, a branch and a working protocol conversation.
02 · pkt-line, the framing everything uses
Before reading the messages, you need the framing. Almost everything both sides say travels as pkt-lines, specified in gitprotocol-common. The rule fits in one sentence: write the total length of the line as 4 hex ASCII digits, counting those 4 bytes themselves, then the payload.
So "a\n" becomes 0006a\n (4 bytes of length + 2 of payload), and "foobar\n" becomes 000bfoobar\n. Both examples are straight from the spec. The payload can be binary, and the maximum is 65516 bytes of payload per pkt.
The magic value is 0000, the flush-pkt: length zero, no payload. It is not an empty line (that would be 0004, which the spec tells implementations not to send). It's a delimiter, the protocol's way of saying "this section is over". Protocol v2 adds two more special values; the one you'll actually see, 0001, the delim-pkt, turns up at the end of this post.
Here is a real pkt-line stream to practice on: the actual ref advertisement our in-page server produces, as one continuous run of bytes, exactly the way it travels. Hover any green length prefix and watch it claim its payload:
Once you can see the frames, the stream stops being a wall of hex and becomes five sentences. Let's read them.
03 · The advertisement
The body of the info/refs response has a fixed shape, and the spec is unusually bossy about it: the client MUST check that the first five bytes match ^[0-9a-f]{4}#, MUST verify the first pkt-line is exactly # service=git-upload-pack, and only then may it trust the rest. After that announcement and a flush comes the actual payload: one pkt-line per ref, sha, space, name, sorted, ending with another flush.
The interesting byte hides in the first ref:
After the ref name there's a NUL byte, and behind it, the server's capability list: everything this server knows how to do beyond the 2005 baseline. The placement is a compatibility trick. A parser that treats the payload as a C string stops reading at the NUL and sees a plain ref line, so the protocol could grow new features without breaking a single old client. The full capability catalog is long; the ones above are the minimal honest set our little server actually implements. A real GitHub advertisement carries about twenty (you'll see one at the end).
One capability deserves a special mention: symref=HEAD:refs/heads/main confesses that HEAD is a symbolic ref pointing at main. That single token is how clone knows which branch to check out. Remember from part two: HEAD on the server is a 21-byte text file, and here it is, leaking through the wire.
04 · want, done, NAK
Now the client speaks, and the vocabulary is tiny. The POST body is pkt-lines again: one want <sha> per branch tip it's after, taken verbatim from the advertisement, then a flush, then done. The first want line doubles as the reply to the capability list: appended after the sha, the client names which of the server's advertised capabilities it wants switched on. Only advertised ones: the spec forbids asking for anything the server didn't offer.
The reply is also how a client opts out of complexity: capabilities it doesn't name stay off. py-git never asks for ofs-delta, so servers send it only ref-deltas, and a 700 line Python client gets away with decoding just one of part three's two delta types.
Where's the negotiation everyone talks about? In the gap between the wants and done. On an incremental fetch, the client would list have <sha> lines there, the commits it already owns, and the server would answer ACK for each one it recognizes, so the pack can skip everything both sides share. That dance has its own spec section and its own post someday. A clone has nothing to declare: no haves, straight to done, which means "send everything reachable from my wants".
The server's answer opens with 0008NAK\n. It looks like an error and isn't: NAK just means "no common base found", which from an empty repository is the only possible outcome. And immediately after it, with no pkt-line framing at all, raw bytes: PACK. The payload of the whole protocol is exactly the format from part three, a packfile, streaming inline in the response body.
05 · Watch a clone happen
Everything is on the table now, so let's run the whole thing. Below, the server on the right holds the part-one repository: 7 objects, main at e268ce4. The client on the left has nothing. Press play, or step through byte by byte; the arrow keys work once the panel has focus. The bytes toggle shows the raw hex of every message, and every node in the graphs opens the same inspector from part one. So does every object sha in the transcript.
Things worth watching for:
- The moment
76309f4lands, the first commit's entire graph snaps together: its tree and blob arrived in the frames before it, and their hashes are the ones you've known since part one. - The tree
d8329fccrosses the wire once, even though both commits reference it (as root tree of the first, asbak/of the second). Content addressing deduplicates on the network exactly like it does on disk. - After the pack's checksum, the network goes quiet. Writing
refs/heads/main, writing HEAD, materializing the worktree: all local. The clone's last act is part two's ref ceremony, performed at home. - The final check: both sides hold identical shas. Not similar. Identical, byte for byte, because the name is the content.
Full disclosure about what you just watched: it's the genuine v0 dialect of the protocol, but the minimal one. Our server advertises no side-band-64k (real servers multiplex progress messages into the stream), no multi_ack refinements, and its pack contains no deltas, each object ships whole. All of that is legal protocol, just the simplest legal conversation. The pack bytes are real enough that git index-pack --strict accepts them unmodified; I checked.
06 · Spying on real git
You don't have to take my word that real traffic looks like this. Git ships a wiretap: set GIT_TRACE_PACKET=1 and every pkt-line is logged as it crosses. Here's a capture of this exact dialect against GitHub, talking to py-git's repository (capability list trimmed for width):
$ GIT_TRACE_PACKET=1 git -c protocol.version=0 ls-remote https://github.com/santiagotoscanini/py-git
packet: ls-remote< 1293709e798c408beeb3ec17736569e52e85d920 HEAD\0multi_ack thin-pack side-band side-band-64k … symref=HEAD:refs/heads/main filter object-format=sha1 agent=git/github-cda1d7094a30-Linux
packet: ls-remote< 1293709e798c408beeb3ec17736569e52e85d920 refs/heads/main
packet: ls-remote< 0000
packet: ls-remote> 0000
Same shape as our figures: first ref, NUL, capabilities, flush. (The trace strips the 4-hex length prefixes from data pkts for readability; the 0000 you see is a real flush.) The client answers with a lone flush: ls-remote only wanted the advertisement, and a flush instead of wants is the polite way to hang up.
Drop the -c protocol.version=0 and the same command speaks protocol v2, the 2018 redesign that modern git defaults to:
packet: ls-remote< version 2
packet: ls-remote< agent=git/github-cda1d7094a30-Linux
packet: ls-remote< ls-refs=unborn
packet: ls-remote< fetch=shallow wait-for-done filter
packet: ls-remote< server-option
packet: ls-remote< object-format=sha1
packet: ls-remote< 0000
packet: ls-remote> command=ls-refs
packet: ls-remote> agent=git/2.55.0-Darwin
packet: ls-remote> object-format=sha1
packet: ls-remote> 0001
packet: ls-remote> peel
packet: ls-remote> symrefs
packet: ls-remote> unborn
packet: ls-remote> 0000
packet: ls-remote< 1293709e798c408beeb3ec17736569e52e85d920 HEAD symref-target:refs/heads/main
packet: ls-remote< 1293709e798c408beeb3ec17736569e52e85d920 refs/heads/main
packet: ls-remote< 0000
packet: ls-remote> 0000
Different grammar, same alphabet. The advertisement no longer dumps refs, it lists commands (ls-refs, fetch) that the client invokes explicitly, and there's the 0001 delim-pkt separating a command's capabilities from its arguments. But it's still pkt-lines end to end, still wants inside command=fetch, and still a packfile at the bottom. Learn v0 and v2 reads like a dialect, not a foreign language.
What I left for another day: the have/ACK negotiation that makes incremental fetches cheap, side-band multiplexing, and the whole push direction, where the roles flip and your machine plays upload... sorry, receive-pack.
The client and server you just watched are a few hundred lines of TypeScript on top of the part-one engine. If you want the same thing in Python, against real GitHub, with the packfile parsing included: that's py-git, the toy implementation this series grew out of. Its clone does exactly what this page does, except the server on the other side is the real one.
That closes the loop the series opened: objects, references, packfiles, and now the wire that carries all three. Four posts, and the fact underneath never changed: it's bytes all the way down, and you can read every one of them.