Git transport

Git over HTTP

What git actually sends over the wire, pkt-lines, and the three routes Ghost serves for it.

Git's "smart HTTP" protocol is three requests. Everything else in the transport exists to serve these well.

RequestPurpose
GET /<owner>/<repo>.git/info/refs?service=git-upload-pack"What refs do you have?" before a fetch or clone
POST /<owner>/<repo>.git/git-upload-pack"I want these commits, I have those": the server answers with a packfile
GET …/info/refs?service=git-receive-pack, then POST …/git-receive-packThe same advertisement before a push, then the push itself: ref commands followed by a packfile

Git builds these paths by appending to the clone URL, which is why they live at the root and not under /api (ADR 0001). The routes are listed once, in apps/api/src/git/git.constants.ts › GIT_TRANSPORT_ROUTES, and excluded from the /api prefix in app.setup.ts.

pkt-lines

Git frames everything in pkt-lines: four hex digits giving the total length (including the four digits themselves), then the payload.

001e# service=git-upload-pack\n      ← 0x1e = 30 bytes, header included
0000                                 ← flush packet: end of a section

apps/api/src/lib/git/protocol/pkt-line.ts is eight lines and worth reading. A push body looks like this:

xxxx0000000000000000000000000000000000000000 3f2a…e1 refs/heads/main\0report-status side-band-64k
0000
PACK…binary packfile…

(xxxx stands for the line's length in hex.)

One command per ref (old-oid new-oid ref, the first carrying capabilities after a NUL byte), a flush, then the pack. An all-zero old oid means "create"; an all-zero new oid means "delete".

That old-oid is git's own compare-and-swap: "move main from X to Y, but only if it is still X". Ghost relocates exactly this check from a lock file on disk to the bucket (ADR 0007).

Stateless RPC

Ghost runs git upload-pack and git receive-pack with --stateless-rpc: one process per HTTP request, reading the request body and writing the response, then exiting. --advertise-refs produces the info/refs answer. apps/api/src/services/git/pack-process/pack-process.service.ts spawns them; apps/api/src/services/git/ref-advertisement/ writes the advertisement.

The controller does one thing

apps/api/src/git/git.controller.ts has three handlers and no logic. Each passes the authorized repository and the request body to GitService and writes back what it returns:

@Post('git-receive-pack')
async receivePack(@Req() req, @Res() res) {
  this.send(res, await this.gitService.receivePack({
    repository: req.repository,
    body: req.gitBody,
    pushedBy: req.actor?.userId ?? null,
    apiKeyId: req.apiKeyId ?? null,
  }));
}

GitService returns { headers, body } as data and never sees Request or Response (ADR 0002). That is why its tests run with no server: they pass a buffer and assert on the returned headers and stream.

Protocol v2

Git can speak protocol v2, where the client asks for only the ref prefixes it needs (ls-refs) instead of receiving every ref first. With thousands of refs/pull/* refs that matters. Ghost passes GIT_PROTOCOL=version=2 to git only when the client sent exactly version=2 in the Git-Protocol header: apps/api/src/lib/git/protocol/git-service.ts › protocolEnv. Nothing a client sends reaches git's environment verbatim.

Try it

Watch the protocol: GIT_TRACE_PACKET=1 GIT_CURL_VERBOSE=1 git ls-remote http://localhost:3001/<you>/<repo>.git 2>&1 | head -40. Find the # service= header, a capability list, and the flush packet.

Check yourself

What does the pkt-line 0009done\n contain?
A push command's old oid is all zeroes. What does the client want?

On this page