objgit Git backed by object storage

Transports

objgitd speaks three git transports. All three answer the protocol natively with the same go-git transport.* functions. objgitd never runs the git binary, and it never writes a checkout to disk.

Transport Flag Default Credential
Smart HTTP -http-bind :8080 HTTP Basic, or anonymous.
git:// -git-bind :9418 Anonymous only.
SSH -ssh-bind off Public key, or anonymous.

All three route their decisions through the auth seam.

git:// (git_protocol.go)

Serve accepts a TCP connection and calls handle. handle decodes a packp.GitProtoRequest. Then it dispatches to transport.UploadPack, transport.UploadArchive, or d.receivePack.

This file also holds the shared operationFor(service) helper. It maps receive-pack to auth.Write, and every other service to auth.Read.

Smart HTTP (http.go)

*daemon implements http.Handler directly. Dispatch reads the URL suffix, which is /info/refs, /git-upload-pack, or /git-receive-pack.

http.ServeMux does not fit this shape. Repository paths have a variable depth, and a ServeMux wildcard cannot capture a prefix that comes before a fixed suffix.

Smart HTTP calls the same go-git server commands with StatelessRPC: true. GET /info/refs also sets AdvertiseRefs: true.

SSH (ssh.go)

newSSHServer builds the gliderlabs/ssh server and the host key. handleSSH dispatches one session, as a sibling of handle.

wish's git/git.go runs the real git-upload-pack binary. objgitd does not.

gliderlabs/ssh splits s.Command() into words for us. gitServiceFor(cmd[0]) selects the service. The repository path is strings.TrimPrefix(cmd[1], "/"), so ssh://host/foo.git and the scp-style host:foo.git resolve to the same repository. The session is the protocol stream, because it is both the reader and the writer.

Connect and authorize are separate steps. PublicKeyHandler returns true for every key. It must be set, or the server does not offer public-key authentication at all. Authorization then happens once per command through d.authz, with Cred: auth.PublicKey{Key: s.PublicKey()}.

The host key lives in the bucket at .objgit/ssh_host_ed25519_key. loadOrCreateHostKey generates an ed25519 key on the first start and reads that same key after that. Clients get no host-key-changed warning across a restart, and the server needs no local disk.

Receive-pack goes through d.receivePack, and not through transport.ReceivePack, so push hooks also run over SSH.

One command is not git. sh <repo> [revision] goes to handleShell in shell.go, before gitServiceFor runs. It opens the hook sandbox as an interactive shell over the session PTY. The optional revision can be a branch name, tag name, full ref name, or full commit hash. Annotated tags are peeled to their target commit. With no revision, it uses the branch named by HEAD. It needs -allow-hooks, a PTY, and auth.Write on the repository, because only a pusher can trigger a hook.

Protocol v2 is not forwarded yet. s.Environ() carries GIT_PROTOCOL, but v0 and v1 are enough for now.

Two protocol points that are easy to get wrong

1. writePack keeps the pack whole

writePack in receivepack.go stores the incoming pack whole on every transport, when the storer supports it.

The default go-git path does not work here. WritePackfileToObjectStorage calls io.CopyBufferPool, which copies until io.EOF. Over a persistent git:// or SSH socket that deadlocks, because the client holds the connection open and waits for report-status.

writePack therefore does not wait for EOF. It drives a packfile.Scanner over an io.TeeReader(rd, packWriter). The scanner knows where the pack ends from the pack framing, which is the header object count plus the trailer checksum. The tee mirrors exactly those bytes into the PackfileWriter.

The result is a few containers per push, instead of one loose write for each object. On S3 each loose write costs a HeadObject for the dedup Lstat, and a PutObject.

internal/storage/tigris.Storer implements storer.PackfileWriter. It stores a flat .bin and .cue container, and not the delta-compressed git pack format. See tigris-storer.md.

A storer without that interface falls back to packfile.UpdateObjectStorage. That path writes one loose object for each SetEncodedObject call.

2. No-op closers everywhere

transport.UploadPack and transport.ReceivePack call Close on the reader between negotiation rounds, and sometimes on the writer. A git:// socket cannot survive that, and an HTTP ResponseWriter does not implement Close.

Every transport therefore wraps the reader in io.NopCloser, and the writer in ioutil.WriteNopCloser from go-git/v6/utils/ioutil.

SSH shares both points with git://, and HTTP does not

An ssh.Session is a persistent two-way stream. handleSSH therefore uses the same no-op closers. It also relies on the same Scanner-bounded writePack, because it has no request-body EOF like the one HTTP enjoys.

3. A push's reference updates are all-or-nothing

updateReferences in receivepack.go used to apply one reference at a time. A failure in the middle left some updates applied and some not.

A storer can now offer a refUpdater, which takes the whole push in one call. The tigris storer has one, and it commits the batch with a single conditional PutObject (see tigris-storer.md). That one call is the commit point, so the batch either lands whole or not at all.

This changes what report-status carries. On failure every command in the push reports the same error, where the first N used to succeed. The protocol permits this, and it matches what git push --atomic means. Per-command rejections still work as before: a command that fails validation never reaches the batch, and it reports its own error.

A storer with no refUpdater keeps the per-reference path, which is what memory.Storage uses in the tests.

The push concurrency cap

Memory during a push scales with the number of pushes in flight, and nothing in the daemon bounds that number. A sweep of concurrent pushes of a 48 MiB pack measured a flat 429 MiB of resident set for each one, with no ceiling. GOMEMLIMIT halves the slope. It does not bound anything, because it makes the collector work harder as the heap grows instead of stopping the heap from growing.

pushlimit.go holds a counting semaphore on *daemon. -max-concurrent-pushes sets the number of slots, and 0 disables the limit. A push that finds every slot taken waits, up to -push-queue-timeout, and then fails.

(*daemon).receivePack is the one place to gate. Smart HTTP and SSH both reach it, and git:// never serves receive-pack at all. Fetches are deliberately not gated. They allocate very differently, and one semaphore over both would let a burst of clones block pushes for reasons that have nothing to do with memory.

The gate itself sits a few lines further in, inside receivePackStreaming, right after the client's capabilities are decoded and before the packfile is read. That is the first point at which the response framing is known, so a push that gives up waiting is reported through sendReportStatus and the person pushing sees error: remote unpack failed: objgitd: too many concurrent pushes.... A gate at the top of receivePack could only drop the connection.

The slot is released by one defer that covers the unpack, the reference update, and the hooks. Watch objgit_push_queue_waiting: see metrics.md.