CLAUDE.md #
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is #
objgitd is a single-binary Git server that stores repositories as objects in a Tigris/S3 bucket instead of on a local filesystem. It speaks three transports against the same backend:
- Smart HTTP (
-http-bind, default:8080) — primary transport. Carries an HTTP Basic credential into the auth seam. - git:// (
-git-bind, default:9418) — unauthenticated TCP, opt-in. - SSH (
-ssh-bind, default off) — public-key transport, opt-in. Host key persisted in the bucket.
All three funnel authorization through one pluggable internal/auth.Authorizer (see The auth seam).
A fourth listener serves Prometheus metrics at /metrics (-metrics-bind, default :9090, empty disables) — see Metrics.
Module path: tangled.org/xeiaso.net/objgit. Go 1.26.
Commands #
go build ./... # build everything
go build -o objgitd ./cmd/objgitd
go test ./... # full test suite
go test ./cmd/objgitd/... # protocol tests (require `git` on PATH; skipped otherwise)
go test -run TestSmartHTTP ./cmd/objgitd/...
# Run locally. Flags can also come from env via flagenv (UPPER_SNAKE of the flag name).
# A .env file in CWD is auto-loaded by godotenv.
./objgitd -bucket $BUCKET -http-bind :8080 -allow-push
./objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push # git clone ssh://git@host:2222/repo.git
SSH tests additionally need ssh and ssh-keygen on PATH (skipped otherwise);
run them with go test -run TestSSH ./cmd/objgitd/....
flagenv maps -allow-push → ALLOW_PUSH, -bucket → BUCKET, etc. Tigris client credentials come from the standard AWS SDK chain (AWS_PROFILE etc.).
Architecture #
The daemon is the shared backend #
cmd/objgitd/main.go constructs one *daemon holding (fs billy.Filesystem, loader transport.Loader, authz auth.Authorizer) (plus the hooks fields) and serves it through all three transports concurrently under an errgroup. Repository resolution, authorization, and create-on-first-push all live on *daemon (loadOrInit in git_protocol.go) so every transport behaves identically.
cmd/objgitd/git_protocol.go— git:// TCP server:Serve→handledecodes apackp.GitProtoRequest, then dispatches totransport.UploadPack/UploadArchive/ReceivePack. Also holds the sharedoperationFor(service)helper (receive-pack →auth.Write, elseauth.Read).cmd/objgitd/http.go—*daemonimplementshttp.Handlerdirectly. Dispatch is by URL suffix (/info/refs,/git-upload-pack,/git-receive-pack) because repo paths are variable-depth andhttp.ServeMuxwildcards can't capture a prefix before a fixed suffix. Smart-HTTP uses the same go-git server commands withStatelessRPC: true(andAdvertiseRefs: trueforGET /info/refs).cmd/objgitd/ssh.go— SSH server (gliderlabs/ssh):newSSHServerbuilds the server and host key;handleSSHis the per-session dispatcher, a sibling ofhandle(see Git over SSH).
Two subtle protocol points #
-
writePackinreceivepack.gostores the incoming pack whole on every transport. go-git's defaultPackfileWriterpath (WritePackfileToObjectStorage→io.CopyBufferPool) copies untilio.EOF, which deadlocks over a persistent git:// / SSH socket (the client holds the connection open awaiting report-status). Instead of relying on EOF,writePackdrives apackfile.Scannerover anio.TeeReader(rd, packWriter): the scanner knows the pack's end from its own framing (header object count + trailer checksum) and stops there, while the tee mirrors exactly those bytes into thePackfileWriter. The result is one.pack+ one.idxper push — no per-object loose writes (which on S3 cost aHeadObjectdedupLstatand aPutObjecteach). This replaced the oldstreamingStorerhack (which hidPackfileWriterto force the loose-objectParser.Parsepath on git:// / SSH); all three transports now sharewritePack. -
No-op closers everywhere.
transport.UploadPack/ReceivePackcallCloseon the reader (and sometimes the writer) between negotiation rounds. The git:// socket can't survive that, and the HTTPResponseWriterdoesn't implementClose. Wrap withio.NopCloser(reader) andioutil.WriteNopCloserfromgo-git/v6/utils/ioutil(writer).
SSH shares both gotchas with git://, not HTTP: an ssh.Session is a persistent bidirectional stream, so handleSSH wraps the session in the same no-op closers and relies on the same Scanner-bounded writePack (gotcha #1) instead of the request-body EOF that HTTP enjoys.
s3fs
ReadAtcontract. Because packs are now read back via go-git'spackfile.FSObject(which probes a packed object's handle with a 1-byteReadAtand reopens the pack onos.ErrClosed),s3fs's read file (internal/s3fs/file.go) returnsos.ErrClosedfromRead/ReadAt/Seekonce closed rather than dereferencing its niled*bytes.Reader. A post-receive hook reading the just-pushed commit hits exactly this path.
The auth seam (internal/auth) #
Every transport authorizes through one interface so a real authn/authz layer can
drop in without touching transport code. internal/auth is deliberately
transport-neutral — it imports only context and golang.org/x/crypto/ssh (for
the public-key wire type), not gliderlabs/ssh or go-git.
Authorizer.Authorize(ctx, auth.Request) auth.Decision. TheRequestcarriesRepo,Operation(Read/Write), aCredential, and aTransporttag.Credentialis a sum type (sealed via an unexported method):Anonymous{}(git://, or HTTP/SSH with nothing presented),PublicKey{Key}(SSH), andBasicAuth{Username, Password}(HTTP — unvalidated; the Authorizer owns the user store).DecisionisAllow/Deny/Unauthenticated.Unauthenticatedis the seam that lets HTTP issue a401 WWW-Authenticatechallenge; SSH and git:// treat anything other thanAllowas denial.
Each transport's job is only to collect the credential, map the git
service to an Operation (via operationFor), and render the Decision in
its own dialect (pktline error / 401/403 / stderr + non-zero exit). The lone
implementation today is auth.AllowAnonymous{AllowWrite}: read for everyone,
write only when set — wired in main.go as AllowAnonymous{AllowWrite: *allowPush},
so -allow-push is now just this default's config rather than a field on daemon.
Metrics (internal/metrics) #
internal/metrics defines every Prometheus vector via promauto against the
default registry, so client_golang's Go-runtime and process collectors are
exported alongside them. main.go serves promhttp.Handler() on its own
listener (-metrics-bind, default :9090) under the same errgroup Serve/Shutdown
idiom as the HTTP transport. All series are prefixed objgit_. Per the operator's
call there is no repo label (unbounded cardinality); git operations are
keyed by protocol+service+status only.
The package exposes thin helpers so call sites carry no label plumbing:
ObserveS3 (the s3fs observer), ObserveGitOp, TrackInFlight (returns a
deferred decrement), ObserveAuth (maps the auth enums to labels), ObserveHook,
and ReposCreated. Three instrumentation seams feed them:
- s3fs reports each S3 round-trip at the API-call level (GetObject, PutObject,
…). s3fs stays prometheus-free: it holds a process-level
func(op, dur, err)observer set vias3fs.SetMetricsObserver, whichmainwires tometrics.ObserveS3. (It is a package-level setter, not an instance Option, because the S3 calls live in standalone constructors that don't hold theS3FS.) - auth routes through one chokepoint,
(*daemon).authorizeingit_protocol.go, which timesd.authz.Authorizeand records the decision before returning it — all three transports call it instead ofd.authzdirectly. - repo ops are wrapped once per transport handler (
handleRPC,handle,handleSSH): an in-flight gauge plus a duration/count keyed by protocol+service. HTTP rolls an authorization denial into theerrorgit status (the precise denial is still visible inobjgit_auth_requests_total); git:// and SSH recorddenieddirectly.
Git over SSH (ssh.go) #
A third sibling to git_protocol.go / http.go. Like git:// (and unlike wish's
git/git.go, which execs the real git-upload-pack binary), objgitd answers the
protocol natively with the same transport.* functions — no git binary, no
on-disk checkout. handleSSH mirrors handle: s.Command() is already
shlex-split by gliderlabs/ssh, so gitServiceFor(cmd[0]) selects the service, the
repo path is strings.TrimPrefix(cmd[1], "/") (so ssh://host/foo.git and
scp-style host:foo.git resolve the same), and the session is the protocol
stream (reader + writer).
- Connect vs. authorize.
PublicKeyHandlerreturnstruefor every key — it must be set or the server won't offer pubkey auth at all — and authorization happens per-command viad.authz(Cred: auth.PublicKey{Key: s.PublicKey()}). - Host key lives in the bucket at
.objgit/ssh_host_ed25519_key(loadOrCreateHostKey): generated ed25519 on first start, reused after, so no host-key-changed warnings across restarts. No local-disk dependency. - Receive-pack goes through
d.receivePack(nottransport.ReceivePack), so push hooks fire over SSH too. - Protocol v2 (
GIT_PROTOCOLvias.Environ()) is intentionally not forwarded yet; v0/v1 is sufficient.
Push hooks (hooks.go, sandboxed via kefka) #
When -allow-hooks is set, a successful receive-pack runs the repository's
.objgit/hooks/receive-pack script. The hook output is streamed to the
pushing client live, rendered as remote: ... lines, so hooks run
synchronously: the client waits for them to finish (bounded by
-hook-timeout) before the push completes. They still cannot reject a push —
they run after refs are updated and report-status is sent (post-receive
semantics). Deleted branches are skipped.
The streaming forces a small fork of go-git's transport.ReceivePack into
cmd/objgitd/receivepack.go (receivePackStreaming): go-git constructs the
sideband Muxer internally and sends the closing flush-pkt before returning, so
there is no public seam to inject remote: progress. The fork adds one
onUpdated(progress io.Writer) callback, invoked after report-status but before
that final flush; progress writes to the sideband ProgressMessage channel
(band 2) when the client negotiated sideband, or is nil otherwise (hooks then
fall back to slog-only output). All three transports call d.receivePack
(hooks.go), which drives the fork: because transport.ReceivePack does not
report which refs it changed, it snapshots branch refs before and after and
diffs them (snapshotRefs/diffRefs) inside onUpdated, then runs each hook
synchronously, streaming through progress. HTTP additionally wraps its
ResponseWriter in a flush-on-write writer (flushWriter in http.go) so
net/http buffering doesn't hold the remote: lines back; git:// and SSH write
through a live socket and need no such wrapper.
The script is read from the pushed commit's tree, so a branch carries its own
hook. It runs in a kefka virtual shell (tangled.org/xeiaso.net/kefka),
which is not an OS sandbox: it is an mvdan.cc/sh interpreter wired to a
billy.Filesystem plus a fixed registry of commands (coreutils only here). The
sandbox filesystem is an internal/mountfs composite of /src (a lazy
read-only internal/treefs view of the commit tree — blobs fetched on open, no
checkout to disk) and /tmp (a writable memfs for scratch; HOME/TMPDIR
point here). Writing anywhere but /tmp fails — and a redirect into /src
aborts the script. Hook stdout/stderr and exit status are logged via slog
only, never relayed to the pusher. internal/kefkash vendors kefka's
unexported billysh handler wiring (its OpenHandler is adapted to permit
writes so /tmp redirections work; the filesystem enforces read-only /src).
internal/s3fs — billy.Filesystem on Tigris #
Vendored from Austin Poor's s3fs and adapted to billy v6 and the Tigris storage-go client. Treats an S3 bucket as a filesystem so go-git's filesystem.NewStorage can store loose objects and packs against it.
The non-obvious piece is tempfs.go. go-git's streaming PackWriter creates a temp pack file, immediately reopens the same path for reading, and reads it back concurrently while writing — to build the index. S3 cannot do that on a single live object, so until the final Rename actually uploads, the bytes live in an in-memory tempBuffer registered on the S3FS by canonical key. readAt returns (0, io.EOF) past the current end so go-git's syncedReader can distinguish "no data yet" from a hard error and retry.
All S3 keys go through S3FS.key → cleanPath + leading-slash strip. Any new S3 op must funnel through there or chroot/path semantics will desync.
internal/slog.go #
Trivial JSON-handler init. The convention across the codebase is slog with "err" (not "error") as the error key.
Conventions #
- Flags use kebab-case and are paired with
flagenvfor env fallback. - Errgroup owns server lifecycle;
signal.NotifyContextprovides cancellation; HTTP shutdown uses a 10scontext.WithTimeout. - Tests are table-driven with
ttand gated byexec.LookPath("git")when they shell out to a real git client (seehttp_test.go,git_protocol_test.go). Shared helpers (runGit,tryGit,seedRepo) live ingit_protocol_test.goand are reused across the package. - Plans for non-trivial work go in
docs/plans/(seegit-http-protocol.mdfor the style).
Tigris / object storage notes #
When working with Tigris buckets, access keys, or IAM, the tigris-storage skills are available. Tigris is S3-compatible; the client used here is github.com/tigrisdata/storage-go (a thin Tigris-aware wrapper that the s3fs layer talks to directly).