stfnw Blog

Git Transfer Protocols

This blog post is an extended write-up of a short talk I gave at a local meetup.

Git status messages are sent from the server#

Have you ever noticed these status messages while cloning a git repository?

$ git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/
Cloning into 'linux'...
remote: Enumerating objects: 11687137, done.
remote: Counting objects: 100% (273/273), done.
remote: Compressing objects: 100% (149/149), done.
Receiving objects:   0% (11709/11687137), 6.32 MiB | 1.74 MiB/s
...

Well, turns out they are sent verbatim by the git server:

Decrypted network traffic (TLS/HTTPS) during git clone shows server-sent sideband status messages

Now you may ask: Can we – with a custom git server – inject our own messages into the unchanged git client?

And the answer is: Yes, we can! This includes sending arbitrary ANSI escape codes (colors, cursor positions, etc.) or simply sending an infinite stream of sideband messages without ever progressing to the actual data, thereby stalling the clone. Which makes this behavior possible:

Note:

  • This sends a dynamically/randomly generated snake game,
  • followed by text of the source code of the tool itself to stdout.
  • The git clone operation is actually successful and the final repository on-disk contains the tool’s source code.

To be clear: the sideband is a documented part of the protocol, nothing here is new. I just found this behavior of verbatim printing to be unexpected and wanted to write about it.

Git transfer protocols#

Once, while cloning a git repository, I wondered what happens underneath. This blog post dives deeper into how git actually transfers data during repository operations like git clone/fetch/pull/push.

From personal experience there are lots of excellent resources about git-internals with respect to how git stores data at rest / the on-disk repository format. However, I found much less material online regarding the transfer protocols.

Resources#

The git source code itself contains extensive documentation about the various transfer protocols. Directly relevant are for example these files:

These documents are also available as manual pages with the same name; e.g. man gitprotocol-v2.

The Git book also contains chapters about the protocols: Git on the Server - The Protocols and Git Internals - Transfer Protocols.

These will form the base sources of the following sections.

One Wire Protocol, several transports#

One key thing to realize is that there is a single underlying protocol – the Git Wire Protocol (version 2). This is then wrapped in slightly varying ways with additional metadata into various transport protocols: file://, git://, ssh://, http/s://.

First, an overview of the different transports (before we dive deeper into the underlying common wire protocol):

transportencryptedauthenticatedwrite accessport
file://n/afilesystem permsyesn/a
git://nonooff by default9418
ssh://yesyesyes22
http://nooptionalsmart only80
https://yes (TLS)optionalsmart only443

file://#

This protocol is used when cloning locally, e.g.:

mkdir -p /tmp/a ; cd /tmp/a ; git init
cd /tmp/ ; git clone a b

In the background, git doesn’t stay in a single process – which one could expect – but instead executes another child process: git-upload-pack. Here is the corresponding output of execsnoop from bcc (the BPF Compiler Collection; Debian ships it as bpfcc-tools), a small tool that prints one line for every process that gets executed on the system:

# execsnoop-bpfcc
...
git              2787271 2615972   0 /usr/bin/git clone a b
sh               2787277 2787271   0 /bin/sh -c git-upload-pack '/tmp/a/.git' git-upload-pack '/tmp/a/.git'
git-upload-pack  2787279 2787277   0 /usr/lib/git-core/git-upload-pack /tmp/a/.git
...

This already hints at the distributed nature of git and the git terminology summarized later regarding clone/fetch/pull and push from the client and server perspectives.

Interestingly, the execution of git-upload-pack goes via an intermediate shell instance /bin/sh.

Parent (client role) and child process (server role) both communicate (most of) the git wire protocol over pipes. To get a first glimpse at the structure of the sent messages, I observed the reads and writes of both processes with strace: (-f follow forks, -o specifies output file, -qq silent/quiet, -e … dump reads from fd0 and writes to fd1 in hex + ascii, -s specify length of strings/buffer contents to dump)

$ strace -f -o /tmp/strace.log -qq -e read=0 -e write=1 -s 10000 git clone a b
Cloning into 'b'...
warning: You appear to have cloned an empty repository.
done.

$ cat /tmp/strace.log | grep 'execve('
3598361 execve("/usr/bin/git", ["git", "clone", "a", "b"], 0x7ffc44b6c648 /* 47 vars */) = 0
3598369 execve("/bin/sh", ["/bin/sh", "-c", "git-upload-pack '/tmp/a/.git'", "git-upload-pack '/tmp/a/.git'"], 0x55c72f30be40 /* 49 vars */ <unfinished ...>
3598374 execve("/usr/lib/git-core/git-upload-pack", ["git-upload-pack", "/tmp/a/.git"], 0x5637e0a54da8 /* 49 vars */ <unfinished ...>

Here are reads (from stdin (file descriptor 0)) and writes (to stdout (file descriptor 1)) of the child process:

$ cat /tmp/strace.log | grep -B1 '^ |' | grep -v -- --
3598374 <... write resumed>)            = 14
 | 00000  30 30 30 65 76 65 72 73  69 6f 6e 20 32 0a        000eversion 2.   |
3598374 <... write resumed>)            = 4
 | 00000  30 30 31 35                                       0015             |
3598374 <... write resumed>)            = 17
 | 00000  61 67 65 6e 74 3d 67 69  74 2f 32 2e 34 37 2e 33  agent=git/2.47.3 |
 | 00010  0a                                                .                |
3598374 <... write resumed>)            = 4
 | 00000  30 30 31 33                                       0013             |
3598374 <... write resumed>)            = 15
 | 00000  6c 73 2d 72 65 66 73 3d  75 6e 62 6f 72 6e 0a     ls-refs=unborn.  |
3598374 <... write resumed>)            = 4
 | 00000  30 30 32 30                                       0020             |
3598374 <... write resumed>)            = 28
 | 00000  66 65 74 63 68 3d 73 68  61 6c 6c 6f 77 20 77 61  fetch=shallow wa |
 | 00010  69 74 2d 66 6f 72 2d 64  6f 6e 65 0a              it-for-done.     |
3598374 <... write resumed>)            = 4
 | 00000  30 30 31 32                                       0012             |
3598374 <... write resumed>)            = 14
 | 00000  73 65 72 76 65 72 2d 6f  70 74 69 6f 6e 0a        server-option.   |
3598374 <... write resumed>)            = 4
 | 00000  30 30 31 37                                       0017             |
3598374 <... write resumed>)            = 19
 | 00000  6f 62 6a 65 63 74 2d 66  6f 72 6d 61 74 3d 73 68  object-format=sh |
 | 00010  61 31 0a                                          a1.              |
3598374 <... write resumed>)            = 4
 | 00000  30 30 30 30                                       0000             |
3598374 <... read resumed>"0014", 4)    = 4
 | 00000  30 30 31 34                                       0014             |
3598374 <... read resumed>"command=ls-refs\n", 16) = 16
 | 00000  63 6f 6d 6d 61 6e 64 3d  6c 73 2d 72 65 66 73 0a  command=ls-refs. |
3598374 <... read resumed>"0014", 4)    = 4
 | 00000  30 30 31 34                                       0014             |
3598374 <... read resumed>"agent=git/2.47.3", 16) = 16
 | 00000  61 67 65 6e 74 3d 67 69  74 2f 32 2e 34 37 2e 33  agent=git/2.47.3 |
3598374 <... read resumed>"0016", 4)    = 4
 | 00000  30 30 31 36                                       0016             |
3598374 <... read resumed>"object-format=sha1", 18) = 18
 | 00000  6f 62 6a 65 63 74 2d 66  6f 72 6d 61 74 3d 73 68  object-format=sh |
 | 00010  61 31                                             a1               |
3598374 <... read resumed>"0001", 4)    = 4
 | 00000  30 30 30 31                                       0001             |
3598374 <... read resumed>"0009", 4)    = 4
 | 00000  30 30 30 39                                       0009             |
3598374 <... read resumed>"peel\n", 5)  = 5
 | 00000  70 65 65 6c 0a                                    peel.            |
3598374 read(0, "000c", 4)              = 4
 | 00000  30 30 30 63                                       000c             |
3598374 read(0, "symrefs\n", 8)         = 8
 | 00000  73 79 6d 72 65 66 73 0a                           symrefs.         |
3598374 read(0, "000b", 4)              = 4
 | 00000  30 30 30 62                                       000b             |
3598374 read(0, "unborn\n", 7)          = 7
 | 00000  75 6e 62 6f 72 6e 0a                              unborn.          |
3598374 read(0, "0014", 4)              = 4
 | 00000  30 30 31 34                                       0014             |
3598374 read(0, "ref-prefix HEAD\n", 16) = 16
 | 00000  72 65 66 2d 70 72 65 66  69 78 20 48 45 41 44 0a  ref-prefix HEAD. |
3598374 read(0, "001b", 4)              = 4
 | 00000  30 30 31 62                                       001b             |
3598374 read(0, "ref-prefix refs/heads/\n", 23) = 23
 | 00000  72 65 66 2d 70 72 65 66  69 78 20 72 65 66 73 2f  ref-prefix refs/ |
 | 00010  68 65 61 64 73 2f 0a                              heads/.          |
3598374 read(0, "001a", 4)              = 4
 | 00000  30 30 31 61                                       001a             |
3598374 read(0, "ref-prefix refs/tags/\n", 22) = 22
 | 00000  72 65 66 2d 70 72 65 66  69 78 20 72 65 66 73 2f  ref-prefix refs/ |
 | 00010  74 61 67 73 2f 0a                                 tags/.           |
3598374 read(0, "0000", 4)              = 4
 | 00000  30 30 30 30                                       0000             |
3598374 <... write resumed>)            = 50
 | 00000  30 30 32 65 75 6e 62 6f  72 6e 20 48 45 41 44 20  002eunborn HEAD  |
 | 00010  73 79 6d 72 65 66 2d 74  61 72 67 65 74 3a 72 65  symref-target:re |
 | 00020  66 73 2f 68 65 61 64 73  2f 6d 61 69 6e 0a 30 30  fs/heads/main.00 |
 | 00030  30 30                                             00               |
3598374 <... read resumed>"0000", 4)    = 4
 | 00000  30 30 30 30                                       0000             |

git://#

This is the Git-native protocol running on TCP port 9418. Since it offers no authentication and no encryption, it is commonly used for anonymous, read-only access.

git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git

ssh://#

This transport uses SSH, and therefore offers encryption and authentication; and allows for both read and write access. It is commonly used with git hosting services.

http:// and https://#

These transports support encryption via TLS (HTTPS), and optionally authentication (e.g. via HTTP basic auth with username and password). They are commonly used with git hosting services.

There are actually two different variations of these transports:

The dumb protocol allows serving a git repository read-only as a static website. The smart protocol allows serving a git repository with additional dynamic features requiring a dedicated server, e.g. write-access, or shallow clones (clones up to a specific commit history depth).

Exploring the Git Wire Protocol V2 in practice#

Now we will analyze the git wire protocol v2 a bit more in detail, based on the (smart) HTTPS protocol. While setting up a git HTTPS server is not as trivial as for the other protocols, this transport is commonly used in the real world, and easy to inspect.

There are different ways we can inspect the network traffic:

As a first approximation, we can use git’s built-in debug options, e.g. with:

GIT_TRACE2=true GIT_TRACE_PACKET=true \
    git clone https://github.com/octocat/Hello-World

Which produces the following output:

... pkt-line.c:85  packet:    git< # service=git-upload-pack
... pkt-line.c:85  packet:    git< 0000
... pkt-line.c:85  packet:    git< version 2
... pkt-line.c:85  packet:    git< agent=git/github-cda1d7094a30-Linux
... pkt-line.c:85  packet:    git< ls-refs=unborn
... pkt-line.c:85  packet:    git< fetch=shallow wait-for-done filter
... pkt-line.c:85  packet:    git< server-option
... pkt-line.c:85  packet:    git< object-format=sha1
... pkt-line.c:85  packet:    git< 0000
... pkt-line.c:85  packet:  clone< version 2
... pkt-line.c:85  packet:  clone< agent=git/github-cda1d7094a30-Linux
... pkt-line.c:85  packet:  clone< ls-refs=unborn
... pkt-line.c:85  packet:  clone< fetch=shallow wait-for-done filter
... pkt-line.c:85  packet:  clone< server-option
... pkt-line.c:85  packet:  clone< object-format=sha1
... pkt-line.c:85  packet:  clone< 0000
... pkt-line.c:85  packet:  clone> command=ls-refs
... pkt-line.c:85  packet:  clone> agent=git/2.55.0-Linux
... pkt-line.c:85  packet:  clone> object-format=sha1
... pkt-line.c:85  packet:  clone> 0001
... pkt-line.c:85  packet:  clone> peel
... pkt-line.c:85  packet:  clone> symrefs
... pkt-line.c:85  packet:  clone> unborn
...

But to fully understand the context and exact bytes sent, and to not have to rely on git-internal self-reporting, a better approach is to externally capture the network traffic exactly as it happens with something like tcpdump or Wireshark.

This is possible, even for the encrypted HTTPS transport, since git uses libcurl:

  • “libcurl” library is used for fetching and pushing repositories over http:// or https://, as well as by git-imap-send. If you do not need that functionality, use NO_CURL to build without it.

Curl supports writing out TLS Master Secrets needed for decryption in Wireshark by setting the environment variable SSLKEYLOGFILE:

wireshark -k -i any & disown
SSLKEYLOGFILE=/tmp/sslkeylogfile \
    git clone https://github.com/octocat/Hello-World

In Wireshark, the TLS streams in the pcap traffic capture can then be decrypted by setting the appropriate options: Edit > Preferences > Protocols > TLS > (Pre)-Master-Secret log filename.

For the further analysis, instead of relying on external git repositories, we set up our own local HTTPS server with a deterministic repository-state to clone. This also gives easier server-side introspection and easier analysis of writes / pushes.

The code and the later referenced pcap files are available in this repository. The Dockerfile can be built/run with the following commands and gates write access behind basic authentication with the credentials test:test:

podman build -t git-http-server .
podman run --rm -p 127.0.0.1:8443:443 -d --name git-http-server localhost/git-http-server

Overview of main commands during git operations#

Git uses the following “command” terminology for different operations from different perspectives/roles (client/server):

clientserver
pushsend-packreceive-pack
clone/pull/fetchfetch-packupload-pack

For example, a clone is a conversation between the client’s fetch-pack and the server’s upload-pack.

Depending on the transport, these are launched as separate independent operating-system processes (which also have their own manual pages man git-send-pack, man git-receive-pack, man git-fetch-pack, man git-upload-pack), or other times they are integrated indirectly into git invocations.

Packet-Line Framing#

Almost everything in the git wire protocol is sent as a so called packet-line (or pkt-line). It is a basic length-value encoding scheme: each packet-line consists of:

  • a 4-byte ASCII-hexadecimal length of the whole pkt-line (including this header itself),
  • followed by variable length data that carries the actually important information.

As an example, when the client queries the server for a listing of remote references (ls-refs), the hexdump of the packet-line looks as follows:

00000000  30 30 31 34 63 6f 6d 6d  61 6e 64 3d 6c 73 2d 72  |0014command=ls-r|
00000010  65 66 73 0a                                       |efs.|

The 0014 = 0x14 = 20 is the length of the whole packet-line. The actual payload is command=ls-refs\n. Regarding the trailing line feed, gitprotocol-pack(5) states, that

the sender SHOULD include a LF, but the receiver MUST NOT complain if it is not present.

Since sometimes the LF is omitted, the referenced code-repository contains a small helper-script show-pktlines.py that displays each pkt-line visually distinct on its own line. It takes as input the Wireshark decrypted HTTPS traffic (“follow HTTP stream”) as a long hex string and outputs the respective pkt-lines contained therein. The output of this script is used as basis for the further code listings of pkt-lines.

As a direct consequence of the length-encoding there are some special pkt-lines. The empty pkt-line (hex 0004) SHOULD NOT be used according to the documentation. Other special pkt-lines are those with a length smaller than the normal possible minimum length:

  • 0000 flush-pkt is used as end-of-message
  • 0001 delim-pkt separates sections of a message
  • 0002 response-end-pkt designates the end of a response

Read access: Git clone#

Here we use the following command as basis for inspecting read access via git clone:

SSLKEYLOGFILE=/tmp/sslkeylogfile GIT_TRACE_PACKET=true \
    git -c http.sslVerify=false \
        clone https://test:test@localhost:8443/git/test.git

Based on this pcap file we will walk in detail through a typical clone operation. The whole exchange, at a glance:

Server(git upload-pack)Client(git fetch-pack)Server(git upload-pack)Client(git fetch-pack)1-2 ⋅ Initial request3-4 ⋅ Command ls-refs5-6 ⋅ Command fetchGET /info/refs?service=git-upload-packcapability advertisementPOST /git/test.git/git-upload-packcommand=ls-refs (peel, symrefs, unborn, ref-prefix, ...)list of references62cb84 HEAD → refs/heads/master62cb84 refs/heads/masterPOST /git/test.git/git-upload-packcommand=fetch (want 62cb84, thin-pack, ofs-delta, done)pack-file, sideband-multiplexed
  1. client > server: At first, we see the client’s initial request to the upload-pack command/service:

    GET /git/test.git/info/refs?service=git-upload-pack HTTP/1.1
    ...
    
  2. server > client: This is then followed by the server capability advertisement:

    000e b"version 2\n"
    0015 b"agent=git/2.47.3\n"
    0013 b"ls-refs=unborn\n"
    0020 b"fetch=shallow wait-for-done\n"
    0012 b"server-option\n"
    0017 b"object-format=sha1\n"
    0000
    

    These pkt-lines have the following meaning:

    • ls-refs unborn: The server supports unborn references. This allows the client to later specify the unborn option when requesting the command ls-refs. An unborn reference is a branch that does not exist yet, e.g. main (or master) on a freshly initialized, empty repository.
    • fetch shallow: This allows the client to later specify a shallow clone, i.e. not the full history but only commits up to a certain depth/data/revision, to keep the local repository copy smaller.
    • fetch wait-for-done: Later, when the pack-file is sent, the server keeps the connection open until the client sends an explicit done, instead of ending the negotiation on its own. I think this allows for more dynamic data flows.
    • server-option: This allows the client to later specify arbitrary key=value server options – an extension point for custom behavior in custom git hooks.
    • object-format sha1: Specifies SHA1 as hash format for the content-addressed object storage. Historically git supported only SHA1. At the latest since SHAttered – a SHA1 hash collision – migration efforts to more modern cryptographically secure hash functions are ongoing and git now also supports SHA-256.
  3. client > server: After this initial handshake, now the client sends the actual first command request: ls-refs; the server should list the references it knows about.

    POST /git/test.git/git-upload-pack HTTP/1.1
    ...
    
    0014 b"command=ls-refs\n"
    0014 b"agent=git/2.47.3"
    0016 b"object-format=sha1"
    0001
    0009 b"peel\n"
    000c b"symrefs\n"
    000b b"unborn\n"
    0014 b"ref-prefix HEAD\n"
    001b b"ref-prefix refs/heads/\n"
    001a b"ref-prefix refs/tags/\n"
    0000
    

    These pkt-lines have the following meaning:

    • command=ls-refs: Request reference advertisement from the server.
    • peel: For annotated tags, the server should show the base non-tag object that the tag refers to. Since an annotated tag is itself an object, tags can point at other tags; peeling resolves that chain in one go. If tag a points to tag b which points to the commit abcdef, the server additionally reports directly that a resolves to abcdef.
    • symrefs: For references, the server should show the underlying object that is referenced.
    • unborn: See description above.
    • ref-prefix: This is a filter for references; the client cares about the references HEAD (current commit/branch), refs/heads/* (server-local branches), and refs/tags/* (tags). This excludes e.g. branches that are remote to the server (e.g. if the server itself has other git remotes configured), or various other reference-types used by other git features.
  4. server > client: The server answers with the requested references.

    0052 b"62cb84d4b62cc9db3329a94ffc3a4800640d039f HEAD symref-target:refs/heads/master\n"
    003f b"62cb84d4b62cc9db3329a94ffc3a4800640d039f refs/heads/master\n"
    0000
    

    Because of the previously specified symrefs option, it includes a second pkt-line resolving the indirection HEAD > refs/heads/master > 62cb84 explicitly.

  5. client > server: Now the client has enough information to request the actual data it wants (the commit of the current branch).

    POST /git/test.git/git-upload-pack HTTP/1.1
    ...
    
    0011 b"command=fetch"
    0014 b"agent=git/2.47.3"
    0016 b"object-format=sha1"
    0001
    000d b"thin-pack"
    000d b"ofs-delta"
    0032 b"want 62cb84d4b62cc9db3329a94ffc3a4800640d039f\n"
    0032 b"want 62cb84d4b62cc9db3329a94ffc3a4800640d039f\n"
    0009 b"done\n"
    0000
    

    These pkt-lines have the following meaning:

    • thin-pack: Client requests that the server send a thin pack: one with deltas which reference base objects not contained within the pack (but known to exist on the receiving end).

      A full description of pack-files is out-of-scope here. In short: pack-files are the main modern on-disk repository object-storage format and also used for transferring data between repositories. They store objects not as distinct individual files, but can store multiple objects in one single file. Besides the base-objects Commit, Tree, Blob, and (annotated) Tag, pack-files also support delta encoding against previously known (by the client) objects for more efficient compression.

      With this option the client asks the server for such deltas, which reference base objects that the client already has. During a fresh clone, this is not relevant. But assume a client pulls updates to an existing repo from a server (on the protocol level it would send have pkt-lines in addition to the want pkt-lines); then this option allows delta-encoding against objects the client already has, without retransmitting them.

    • ofs-delta: Client supports the modern PACKv2 format with delta by offset instead of oid (hash).

      The previously mentioned delta encoding in pack-files can reference other objects by two mechanisms: Either oid (objectid, =hash), or by relative-offset into the current pack-file. The first method is the older one and aligns with the content-addressable nature of git. The second method was introduced later. This option allows the server to use both methods.

    • want: Objects to retrieve; identified by their hash (object id).

      It was surprising to me that the same hash appears twice here; I suppose the client simply doesn’t de-duplicate hashes referenced by multiple names.

    • done: Indicates to the server that negotiation is finished and that the server should now send the actual repository data (pack-file).

  6. server > client: Now the server sends the actual data, interleaved/multiplexed with status information:

    000d b"packfile\n"
    0023 b"\x02Enumerating objects: 3, done.\n"
    0022 b"\x02Counting objects:  33% (1/3)\r"
    0022 b"\x02Counting objects:  66% (2/3)\r"
    0046 b"\x02Counting objects: 100% (3/3)\rCounting objects: 100% (3/3), done.\n"
    00c5 b"\x01PACK\x00\x00\x00\x02\x00\x00\x00\x03\x93\x07x\x9cu\xc9A\n\x80 \x10"
         b"\x05\xd0\xbd\xa7\x98}\x1bK\xd3\x84h\xd7AF\xe7GAR\xc4\x04\x1d\xbf.\xd0"
         ...
    0043 b"\x02Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)\n"
    0006 b"\x01\xa0"
    0000
    

    These pkt-lines have the following meaning:

    • First, there is a fixed header pkt-line packfile

    • Then follow the multiplexed data streams, identified by their id 1-3:

      • Stream 1: actual pack data
      • Stream 2: progress messages
      • Stream 3: error messages
    • Stream 2 is used in the initial demo to inject arbitrary messages from the server to the client. The messages are printed verbatim including control characters; it is possible to send many sideband progress messages, without continuing sending actual pack data.

As we can see, the protocol offers many parameters. It should also be noted that this is only one specific workflow; since later protocol steps depend on earlier specified parameters, there are many possibilities.

Write access: Git push#

In the same vein we can also record a push operation. Based on this pcap file we will walk in detail through a typical push operation. The whole exchange, at a glance:

Server(git receive-pack)Client(git send-pack)Server(git receive-pack)Client(git send-pack)1-2 ⋅ Ref discovery (unauthenticated)3-4 ⋅ Retry with credentials5-6 ⋅ Push objectsGET /git/test.git/info/refs?service=git-receive-pack401 UnauthorizedGET /git/test.git/info/refs?service=git-receive-packAuthorization: Basic dGVzdDp0ZXN062cb84 refs/heads/masterreport-status ⋅ delete-refs ⋅ side-band-64k ⋅ atomic ⋅ ofs-deltaPOST /git/test.git/git-receive-packold 62cb84 new b6d98e refs/heads/master + PACKunpack okok refs/heads/master

For this, we first have to introduce some changes to our local repository. Here I chose to make these changes bit-for-bit reproducible by fixing all metadata fields in commits (e.g. timestamps), so that anyone who follows along can get an exact replica of the repository state (including all object hashes). The example change is the following one:

cd test/
echo 'some changes' >> README.md
git add .

export GIT_AUTHOR_NAME='Example'
export GIT_AUTHOR_EMAIL='<>'
export GIT_AUTHOR_DATE='1970-01-01T01:00:00+00:00'
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"

git commit -m update

Now we can push with:

SSLKEYLOGFILE=/tmp/sslkeylogfile GIT_TRACE_PACKET=true \
    git -c http.sslVerify=false push
  1. client > server: At first, the client requests the server-side receive-pack function (without any credentials):

    GET /git/test.git/info/refs?service=git-receive-pack HTTP/1.1
    ...
    
  2. server > client: Since no credentials were specified by the client, the server in the specified configuration rejects the push and answers with:

    HTTP/1.1 401 Unauthorized
    ...
    
  3. client > server: The client tries the initial request again, this time with http basic auth credentials for test:test (dGVzdDp0ZXN0 is just base64 for test:test, these credentials were saved from the initial clone url):

    GET /git/test.git/info/refs?service=git-receive-pack HTTP/1.1
    ...
    Authorization: Basic dGVzdDp0ZXN0
    ...
    
  4. server > client: Now the server responds with its current main branch and the capabilities it supports:

    001f b"# service=git-receive-pack\n"
    0000
    00b3 b"62cb84d4b62cc9db3329a94ffc3a4800640d039f refs/heads/master\x00"
         b"report-status report-status-v2 delete-refs side-band-64k quiet "
         b"atomic ofs-delta object-format=sha1 agent=git/2.47.3\n"
    0000
    

    The individual options have the following meaning (man gitprotocol-capabilities):

    • report-status and report-status-v2: Server supports sending extra status information.
    • delete-refs: Client can delete references on the server (e.g. delete a remote branch).
    • side-band-64k: Server supports receiving multiplexed pack-files in larger chunks than the old default.
    • quiet: Server supports not sending any non-essential progress reports.
    • atomic: Server supports handling the whole push update as one single atomic transaction (all-or-nothing).
    • ofs-delta: Server supports receiving pack-files delta-encoded against internal relative pack-file offsets (refer to section above about delta encoding of pack-files).
    • object-format=sha1: Server uses SHA1 as hash-function for identifying objects.
    • agent=git/2.47.3: Version string for the server git installation.
  5. client > server: Client sends the actual push as a pack-file:

    POST /git/test.git/git-receive-pack
    ...
    
    00ab b"62cb84d4b62cc9db3329a94ffc3a4800640d039f b6d98ea54a92dfe448211ba001427b4047704270 "
         b"refs/heads/master\x00 report-status-v2 side-band-64k object-format=sha1 agent=git/2.47.3"
    0000 b"PACK\x00\x00\x00\x02\x00\x00\x00\x03\x93\nx\x9cu\x8bA\x0e\x02!\x0c\x00"
         b"\xef\xbc\x82\xbb\x97\xba\xd4B\x13\xe3\xcd\x87\x14Z\xa2\x89\xb8\x84\xb0"
         ...
    

    The individual options have the previously described meaning from step 4.

    Note that the pack data follows the flush-pkt 0000 directly as raw bytestream, not wrapped inside a pkt-line.

  6. server > client: Server gives client a final progress report that everything was processed correctly:

    0030 b"\x01000eunpack ok\n0019ok refs/heads/master\n0000"
    0000
    

The source code behind git clone https://infinite-git.stfnw.de is available in that repo itself, or at github.com/stfnw/infinite-git.
The talk slides are available at github.com/stfnw/talk-understanding-git-transfer-protocols.