Git Wire Protocol v2: A Deep Dive
Recently, I’ve been messing about with Git server stuff for some experimental work for GitButler. In doing that, one of the things that I’ve been digging into is the bundle-uri and packfile-uri capabilities.
Looking more into it made me realize that I’m not as familiar with how Git’s server works these days as I thought I was. Specifically, I had never really learned about Git’s new (8 years old) “v2” server protocol, which is what those capabilities are built on. So I did a bit of a deep dive.
Smart HTTP, First Edition
When I published the first version of the Pro Git book in August of 2009, Git barely had HTTP support at all. What it had then is now referred to as the “dumb” protocol, just serving static files directly over HTTP.
In December of 2009, Git 1.6.6 landed with the first “smart” HTTP protocol - a CGI script you could load into Apache or whatever to handle ref advertisement and object negotiation via the same git-upload-pack and git-receive-pack binaries that SSH runs via git-shell for communication.
That first “smart” HTTP support landed as part of a huge 28-patch series from Shawn Pearce — the origin of git-http-backend and the smart client and server we still use:
Hopefully the final spin of the smart HTTP series. I think we are down to bike shedding, which I take it means folks are otherwise reasonably happy with the code.
Read the first message →
Since this landed after Pro Git (ed 1) was published, one of the bigger updates in the second edition of the book was to describe HTTP as a first-class protocol to use. I actually wrote a blog post here in 2010 outlining the new “smart” HTTP.
HTTP Protocol V2
I published the second edition of Pro Git in November of 2014 updated with these changes, and a few years later the HTTP transport got smarter again with the v2 protocol.
Since I wasn’t as heavily into Git stuff for a bit, I sort of missed what this meant and why it was done, so I’ve recently dug into it. If you’re curious how Git’s modern HTTP protocol works, this is the blog post for you.
Why a New Protocol?
The first version of the smart protocol would basically do a dump of every reference when you first connected, so the client could determine what objects it had or needed and continue to the negotiation or object push.
That’s fine for most projects, but when your repository has hundreds of thousands of refs, it becomes a lot of data to send over the wire every time you want to do anything at all. So one of the first things the new protocol could do was filter the refs advertised with the ls-refs command.
Another problem was capabilities advertisement. The first version sort of shoehorned them in to the first reference line, hidden behind a NUL byte, which means a server could never advertise more than the size of a pkt-line.
Finally, instead of multiple service names, in v2, multiple commands can be supported by a single service. This means that instead of adding new commands by adding new http endpoints, the git-upload-pack service can now be asked to do lots of different things other than producing packfiles (like listing filtered references, for example).
To lay the foundation to address these issues, Brandon Williams of Google introduced git-serve in March 2018, along with the first two capabilities, ls-refs and fetch.
ed10cb95 serve: introduce git-serve The introduction of the v2 capable server
It finally became the default protocol for the client in Git 2.29, released in October 2020.
Anatomy of a Git HTTP conversation
OK, so what did an HTTP conversation look like with the first protocol and what does it look like with v2? Let’s walk through a clone of a small repository to see the difference.
The First Protocol
Originally, the “Smart” HTTP conversation went something like this.
For a clone, the client would make two HTTP round trips. One to figure out what the reference tips (branches) are. The second to tell the server what it wanted so the server could generate a packfile of just the data needed.
Let’s look at each HTTP request in a bit of detail. The first roundtrip is a GET request that asks for references and server capabilities.
C→S GET /schacon/soe.git/info/refs?service=git-upload-pack C→S Accept: */* S→C 200 OK Content-Type: application/x-git-upload-pack-advertisement S→C 001e# service=git-upload-pack S→C 0000 S→C ….ee93348…cf9e3 HEAD\0multi_ack thin-pack side-band-64k ofs-delta shallow deepen-since deepen-not no-done multi_ack_detailed allow-tip-sha1-in-want allow-reachable-sha1-in-want symref=HEAD:refs/heads/main filter object-format=sha1 agent=git/github-…-Linux S→C ….ee93348…cf9e3 refs/heads/main S→C ….9cb163a…8292 refs/heads/sc-branch-1 S→C ….9cb163a…8292 refs/pull/1/head S→C 0000
- ?service=
git-upload-pack Selects the service.git-upload-packmeans "I want to fetch.",git-receive-packmeans "I want to push". - 001e# service=… A smart-HTTP-only preamble that lets the client confirm it reached a smart server (not a dumb file server), followed by a
0000flush. - HEAD\0…caps Here you can see the capabilities list is just tucked behind a NUL byte after the first ref. This allowed the addition of capabilities without technically changing the protocol, but it's why the capability list was size-limited and awkward to grow.
- symref=HEAD:
refs/heads/main Another sort of hack that allows the smart protocol to smuggle the fact thatHEADis symbolic, because there was nowhere else to put this information. - refs/pull/1/head Finally, notice that it's advertising every reference. On a large repo this can result in a huge amount of data over the wire before anything else.
The second request is a POST body that says what heads we want data for and the response is the packfile and sideband information to allow the client to show progress as it’s downloading the streamed body.
C→S POST /schacon/soe.git/git-upload-pack C→S Content-Type: application/x-git-upload-pack-request C→S Content-Length: 234 C→S 00abwant ee93348…cf9e3 multi_ack_detailed no-done side-band-64k thin-pack no-progress ofs-delta agent=git/2.52.0-Darwin C→S 0032want 9cb163a…8292 C→S 0000 C→S 0009done S→C 200 OK Content-Type: application/x-git-upload-pack-result S→C 0008NAK S→C ….\x01PACK…binary packfile… # side-band channel 1 S→C ….\x02Enumerating / Compressing… # channel 2: progress S→C 0000
- want <oid> caps The client asks for objects by object id (not by name). It wants both tips —
ee93348…(main/HEAD) and9cb163a…(sc-branch-1). The capabilities it actually intends to use ride on the firstwantline. - 0000 then done A flush ends the
wantlist;donetells the server to stop negotiating and just send a pack. On a fresh clone there are nohavelines — the client has nothing yet. On a fetch we would also send the stuff we already have so the server can determine the smallest packfile needed. - NAK "No common commit found". Because this is a clone. In an incremental fetch this is where
ACK <oid>lines would appear as the two sides find shared history. - \x01 / \x02 The packfile is multiplexed on a side-band: a leading byte per chunk marks channel 1 (pack bytes), 2 (progress), or 3 (error). This is so the client can do the "Receiving objects…" meter that you see on a clone.
The v2 Protocol
Here’s that same clone, this time in v2. This time it’s three round trips rather than two. The first just to determine capabilities. Then a ref advertisement using the ls-refs command. Then a packfile request using the fetch command.
Let’s take a look at each of them.
C→S GET /schacon/soe.git/info/refs?service=git-upload-pack C→S Git-Protocol: version=2 # ← the trigger for v2 S→C 200 OK Content-Type: application/x-git-upload-pack-advertisement S→C 000eversion 2 S→C ….agent=git/github-…-Linux S→C ….ls-refs=unborn S→C ….fetch=shallow wait-for-done filter S→C ….server-option S→C ….object-format=sha1 S→C 0000
- Git-Protocol:
version=2 This new header lets the server know that we're dealing with a v2 capable client so it knows to respond differently. - version 2 The server confirms it speaks v2.
- ls-refs= / fetch= These capabilities are commands that the client can invoke. Their values advertise sub-features:
fetchhere supportsshallow,wait-for-done, andfilter(partial clone).
OK, so now the client knows what the server can do, including what object format the repository is in (sha1 or sha256).
Now that we know that, we can then start POSTing commands to run. For a clone, we start with ls-refs, which is similar to the first GET we did in the previous protocol, but crucially we can filter the returned values.
This is a little funny, because now in v2 the GET to /info/refs does not return refs, it returns capabilities. Then a POST to /git-upload-pack with a ls-refs command is used to actually return the refs we want to inspect (as upload-pack is now just a service endpoint used to run any of several commands).
command=ls-refs C→S POST /schacon/soe.git/git-upload-pack (Content-Length: 182) C→S command=ls-refs C→S agent=git/2.52.0-Darwin C→S object-format=sha1 C→S 0001 # delimiter: capabilities end, args begin C→S peel C→S symrefs C→S unborn C→S ref-prefix refs/heads/ C→S ref-prefix refs/tags/ C→S ref-prefix HEAD C→S 0000 S→C ….ee93348…cf9e3 HEAD symref-target:refs/heads/main S→C ….ee93348…cf9e3 refs/heads/main S→C ….9cb163a…8292 refs/heads/sc-branch-1 S→C 0002 # response-end (stateless HTTP)
- command=ls-refs The first line of the POST body is the actual command to run. Here we're asking to run ls-refs
- 0001 delimiter Separates the capabilities the client is using from the command's arguments. (A
0000flush ends the whole request.) - peel Show peeled tags — appends peeled:
to a tag's line (the commit the tag ultimately dereferences to). - symrefs For a symbolic ref, also show what it points at — appends symref-target: (e.g. HEAD symref-target:refs/heads/main).
- ref-prefix … Here is the filtering argument. The client asks only for
refs/heads/,refs/tags/, andHEAD. It could also only ask for a single ref. - symref-target: The symbolic-ref info now has a proper place in the response, instead of being smuggled into a capability string as it was in v0.
- 0002 The response-end-pkt, so the client can tell the response is complete over a stateless HTTP POST.
Now we have a list of the objects we want and can ask for the packfile with the fetch command (basically the only thing git-upload-pack did before v2).
command=fetch C→S POST /schacon/soe.git/git-upload-pack (Content-Length: 274) C→S command=fetch C→S agent=git/2.52.0-Darwin object-format=sha1 C→S 0001 C→S thin-pack no-progress ofs-delta C→S want ee93348…cf9e3 C→S want ee93348…cf9e3 C→S want 9cb163a…8292 C→S done C→S 0000 S→C ….packfile S→C ….\x01PACK…binary packfile… # side-band channel 1 S→C 0002
- command=fetch Again, the command to run. This time we're going to tell the server what we want and have and it will generate a custom packfile for us.
- done A fresh clone has nothing to negotiate, so the client sends
done. Because it did, the server skips theacknowledgmentssection and jumps straight to the pack data - packfile A named response section. v2 responses are structured into labelled sections (
acknowledgments,shallow-info,wanted-refs,packfile); here only the pack is needed. - \x01 … 0002 The pack is side-band-multiplexed just like the old protocol, then
0002ends the response.
In this simple example, in practice, there is actually a lot more going over the wire in v2 than in the first version of the protocol. There are 3 round trips consisting of 456 bytes, instead of 2 and 234 bytes.
However, in practical terms, v2 normally saves a lot of traffic because it can filter the references it cares about. Some larger repositories have many megabytes of reference data, so that first refs advertisement is killer overhead.
What all can a Git server do nowadays?
So this is really mostly what I didn’t know. What can a git server do these days outside of listing references and negotiating packfiles? What all commands can you tell it to advertise and allow clients to invoke?
The reality is that out of the box, it’s pretty much nothing. Outside of ls-refs and fetch the vanilla server only advertises server-option and object-format, which aren’t actually commands, they’re just simple ways to pass other data (to send arbitrary values to custom servers like Gerrit, and tell the client the hash-format).
There are other commands (like the bundle-uri and packfile-uri I mentioned at the start of the article), but they’re all gated behind server (and sometimes also client-side) config options.
Here is a full list of the current potential capabilities of a v2 Git server (green rows are enabled by default, gray must be configured on) and when they were introduced.
| Capability | Since | Does | Purpose | |
|---|---|---|---|---|
| ls-refs | 2.18 '18 | Refs on demand, with prefix filtering | Core | |
| Turns ref listing into an explicit command that takes ref-prefix arguments, so a client can ask for just the refs it wants instead of the full advertisement v1 dumped up front. Every v2 clone and fetch starts here. | ||||
| fetch | 2.18 '18 | Packfile negotiation | Core | |
| The v2 object-negotiation command — v1's fetch with the ref advertisement stripped out (ls-refs handles that now) and the message format cleaned up so it can be extended. Runs on every clone and fetch. | ||||
| server-option | 2.18 '18 | Pass server-specific options | Extensibility | |
| A generic channel for passing server-specific options ( | ||||
| fetch.shallow | 2.18 '18 | Shallow / --depth clones & deepening | Shallow clone | |
| Brings shallow clones and history deepening to v2's fetch — | ||||
| fetch.filter | 2.18 '18 | Partial clone (blobless/treeless) | Partial clone | |
| Usage Adds partial-clone filter specs to v2's fetch, which was first designed without them — Config value Server uploadpack.allowFilter | ||||
| fetch.ref-in-want | 2.19 '18 | Request refs by name | Server farms | |
| Usage Lets a client name wanted refs instead of object ids, closing the race where a ref changes between the advertisement and the fetch — the kind of thing that happens on busy, load-balanced server farms. Specialized; mostly relevant to large hosts. Config value Server uploadpack.allowRefInWant | ||||
| fetch.sideband-all | 2.21 '19 | Multiplex the whole response | Progress | |
| Usage Extends the multiplexed side-band over the entire fetch response, not just the packfile, so the server can send progress and keepalives before the pack begins — a gap the CDN-offload work exposed. Opt-in and niche. Config value Server uploadpack.allowSidebandAll | ||||
| object-format | 2.28 '20 | SHA-256 negotiation | SHA-256 | |
| Negotiates the repository's hash algorithm during the handshake — the wire-level enabler for the SHA-256 transition. Always exchanged in v2, though SHA-256 repos themselves are still rare. | ||||
| fetch.packfile-uris | 2.28 '20 | CDN offload of pack data | CDN offload | |
| Usage Lets the server hand back Config value Server uploadpack.blobPackfileUri Client fetch.uriProtocols | ||||
| session-id | 2.30 '20 | Diagnostic session correlation | Diagnostics | |
| Usage Both sides advertise a unique session id so client and server trace logs can be correlated when debugging a remote operation. Purely diagnostic — valuable to operators, invisible to users. Config value Server + client transfer.advertiseSID | ||||
| object-info | 2.32 '21 | Object size without download | Disabled | |
| Usage Lets a client ask for object metadata (currently just size) by oid without downloading the objects. Very rare: server-side only, no in-tree client ever shipped, and it's been disabled by default since 2024 to shrink attack surface. Config value Server transfer.advertiseObjectInfo | ||||
| fetch.wait-for-done | 2.32 '21 | Negotiate without a packfile | Negotiation | |
| Makes the server wait for the client instead of unilaterally sending a pack, so negotiation can run on its own — the building block for push negotiation and | ||||
| bundle-uri | 2.40 '22 | Seed a clone from bundle URLs | Clone speedup | |
| Usage Points the client at Config value Server uploadpack.advertiseBundleURIs Client transfer.bundleURI | ||||
| promisor-remote | 2.49 '25 | Advertise promisor remotes | Large objects | |
| Usage Lets the server tell the client which promisor remote it uses, so the client can lazily fetch missing (often large) objects straight from that better-connected source instead of passing Config value Server promisor.advertise Client promisor.acceptFromServer | ||||
Now, there is a lot to potentially dig into here (perhaps a future blog post), but what’s really interesting is how old some of these capabilities are and how very, very rarely used they are.
Nearly all of them landed over 5 years ago and nearly none of them are available on any major code host. GitHub and GitLab, for example, only enable fetch.filter so you can do blobless clones. None of the rest of these capabilities are enabled (well, GitLab can do bundle-uris on self-hosted instances, I think, but not by default).
Honestly, most of this is by and for Google. 11 of the 14 capabilities were contributed by Google and nearly all of the configurable options were also contributed by them. So, most of v2 seems to be so Google can scale Git weirdly internally.
But it’s cool.
See it for yourself
If you want to check any of this out yourself, it’s actually pretty easy to see what is going over the wire.
If you set the GIT_TRACE_PACKET environment variable and run Git networking stuff, it will show you what it’s doing.
$ GIT_TRACE_PACKET=1 git ls-remote https://github.com/schacon/soe
12:37:45.271670 pkt-line.c:85 packet: git< # service=git-upload-pack
12:37:45.272656 pkt-line.c:85 packet: git< 0000
12:37:45.272691 pkt-line.c:85 packet: git< version 2
12:37:45.272716 pkt-line.c:85 packet: git< agent=git/github-cda1d7094a30-Linux
12:37:45.272732 pkt-line.c:85 packet: git< ls-refs=unborn
12:37:45.272747 pkt-line.c:85 packet: git< fetch=shallow wait-for-done filter
12:37:45.272760 pkt-line.c:85 packet: git< server-option
12:37:45.272773 pkt-line.c:85 packet: git< object-format=sha1
12:37:45.272785 pkt-line.c:85 packet: git< 0000
12:37:45.272956 pkt-line.c:85 packet: ls-remote< version 2
12:37:45.274390 pkt-line.c:85 packet: ls-remote< agent=git/github-cda1d7094a30-Linux
12:37:45.274425 pkt-line.c:85 packet: ls-remote< ls-refs=unborn
12:37:45.274446 pkt-line.c:85 packet: ls-remote< fetch=shallow wait-for-done filter
12:37:45.274463 pkt-line.c:85 packet: ls-remote< server-option
12:37:45.274477 pkt-line.c:85 packet: ls-remote< object-format=sha1
12:37:45.274491 pkt-line.c:85 packet: ls-remote< 0000
12:37:45.274508 pkt-line.c:85 packet: ls-remote> command=ls-refs
12:37:45.274552 pkt-line.c:85 packet: ls-remote> agent=git/2.52.0-Darwin
12:37:45.274576 pkt-line.c:85 packet: ls-remote> object-format=sha1
12:37:45.274594 pkt-line.c:85 packet: ls-remote> 0001
12:37:45.274561 pkt-line.c:85 packet: git< command=ls-refs
12:37:45.274612 pkt-line.c:85 packet: ls-remote> peel
12:37:45.274656 pkt-line.c:85 packet: git< agent=git/2.52.0-Darwin
12:37:45.274714 pkt-line.c:85 packet: ls-remote> symrefs
12:37:45.274744 pkt-line.c:85 packet: git< object-format=sha1
12:37:45.274779 pkt-line.c:85 packet: ls-remote> unborn
12:37:45.274819 pkt-line.c:85 packet: git< 0001
12:37:45.274893 pkt-line.c:85 packet: git< peel
12:37:45.274919 pkt-line.c:85 packet: git< symrefs
12:37:45.274860 pkt-line.c:85 packet: ls-remote> 0000
12:37:45.274968 pkt-line.c:85 packet: git< unborn
12:37:45.275097 pkt-line.c:85 packet: git< 0000
12:37:45.490316 pkt-line.c:85 packet: git> 0002
12:37:45.490332 pkt-line.c:85 packet: ls-remote< ee93348afd51e0d634d9e84a702af190d45cf9e3 HEAD symref-target:refs/heads/main
12:37:45.490569 pkt-line.c:85 packet: ls-remote< ee93348afd51e0d634d9e84a702af190d45cf9e3 refs/heads/main
12:37:45.490611 pkt-line.c:85 packet: ls-remote< 9cb163aaa43273f49de0182416d3a621b3ae8292 refs/heads/sc-branch-1
12:37:45.490646 pkt-line.c:85 packet: ls-remote< 9cb163aaa43273f49de0182416d3a621b3ae8292 refs/pull/1/head
12:37:45.490671 pkt-line.c:85 packet: ls-remote< 0000
12:37:45.490874 pkt-line.c:85 packet: ls-remote< 0002
ee93348afd51e0d634d9e84a702af190d45cf9e3 HEAD
ee93348afd51e0d634d9e84a702af190d45cf9e3 refs/heads/main
9cb163aaa43273f49de0182416d3a621b3ae8292 refs/heads/sc-branch-1
9cb163aaa43273f49de0182416d3a621b3ae8292 refs/pull/1/head
Well, that’s it. Hope you learned something, because I certainly did.