How Git Push Actually Works Over HTTP

rubytoolingbuilding-in-public

When you type git push origin main, your git client and the server have a conversation. It’s a protocol called “smart HTTP” and it’s been the default transport for git-over-HTTPS since Git 1.6.6. Understanding how it works was essential for building Codex’s git backend, and the first big decision was what NOT to build.

The smart HTTP protocol

Git’s smart HTTP transport uses two endpoints:

Before either of these, there’s a discovery step: GET /repo.git/info/refs?service=git-upload-pack (or git-receive-pack). The server responds with the current refs (branches, tags) and its capabilities. The client uses this to figure out what needs to be transferred.

The actual packfile negotiation, the “what objects do you have, what do I need to send you” dance, is handled by git itself. It’s a binary protocol layered inside HTTP request/response bodies, with length-prefixed “pkt-lines” that carry ref advertisements, have/want lists, and eventually the compressed packfile.

Why we didn’t reimplement it

Reimplementing the git wire protocol would mean handling packfile generation, delta compression, object negotiation, shallow clone support, and a dozen other things that git already does correctly. It’s a substantial amount of code for zero business value. The protocol isn’t where our requirements differ from what exists.

What we need is the layer on top: authentication (who is this?), authorization (can they push to this repo?), and policy enforcement (does this push violate any rules?). Those are our problems. The wire protocol is git’s problem.

So Codex wraps the system’s native git-http-backend, the CGI program that ships with git. The architecture:

  1. Git client sends an HTTP request to Codex
  2. Rails authenticates the request (HTTP Basic with password or access token, or Bearer JWT)
  3. Rails checks RBAC permissions (can this entity pull? can they push?)
  4. The HttpBackendAdapter builds a CGI environment and pipes the request to git-http-backend via Open3.popen3
  5. git-http-backend does the actual git protocol work
  6. The CGI response is parsed and streamed back to the client

The CGI environment setup is the interesting part. GIT_PROJECT_ROOT points to where bare repositories live on disk. PATH_INFO tells git which repository. REMOTE_USER carries the authenticated identity. We also pass custom environment variables, CODEX_ENTITY_TYPE and CODEX_ENTITY_ID, so that git hooks (which run inside the CGI process) know who triggered the push.

Finding git-http-backend

One annoying practical problem: git-http-backend lives in different places on different systems. On macOS with Homebrew (Apple Silicon) it’s at /opt/homebrew/Cellar/git/.../git-http-backend. On macOS Intel, /usr/local/.... On Debian/Ubuntu, /usr/lib/git-core/git-http-backend. On other Linux distributions, who knows.

Our adapter tries AppSettings.git_http_backend_path first (configurable), then which git-http-backend, then a list of known paths for macOS and Linux. It’s the kind of thing that seems trivial until you deploy to a different OS and nothing works.

Hooks: where the policy enforcement happens

The git protocol has a hook system: scripts that run at specific points during a push. Codex generates three bash scripts in each bare repository’s hooks/ directory:

pre-receive runs before any refs are updated. Reads old-rev, new-rev, and refname from stdin, and POSTs them to Codex’s internal hook endpoint. If Codex responds with anything other than 200, the entire push is rejected. This is where we enforce push policies: no secrets in code, signed commits required, commit message patterns, file size limits.

update runs once per ref being updated. Same pattern: POST to Codex, reject if non-200. This handles per-ref policies: no force push to main, no tag deletion, no branch deletion on protected branches.

post-receive runs after refs are updated. Fires-and-forgets to Codex (always exits 0, never blocks the push). This triggers commit signature verification, activity feed updates, PR status checks, and event fan-out.

The hook scripts are generated when a repository is initialized. They curl back to localhost with the entity information that was passed through the CGI environment. The hook endpoints are restricted to localhost-only connections. External requests get rejected.

The bare repository layout

Repositories on disk are bare git repos (no working tree) at {storage_root}/repositories/{PROJECT_KEY}/{repo_slug}.git. They’re initialized with Rugged (libgit2 Ruby bindings) and managed entirely through Rugged for read operations (tree traversal, commit walking, diff generation, blame) and through git-http-backend for write operations (push/pull).

This split, Rugged for reads, native git for writes, is deliberate. Rugged gives us fast, in-process access to git objects without shelling out. But the push/pull protocol is complex enough that delegating to git’s own implementation is the right call.

The result is a git backend that does exactly what we need: authentication, authorization, policy enforcement. No reimplementation of the protocol machinery that git already handles.