objgitd currently serves repositories out of a Tigris/S3-backed billy filesystem
over the git:// (TCP) protocol only (cmd/objgitd/daemon.go). git:// is
unauthenticated, hard to put behind a normal HTTP proxy, and not what most hosts
(or CI systems) expect. We want clients to be able to clone/fetch/push over
HTTP smart protocol (https://host/repo.git), which is the standard transport
and the natural place to bolt on auth later via middleware.
go-git v6's server commands are already HTTP-ready: transport.UploadPack and
transport.ReceivePack expose AdvertiseRefs bool and StatelessRPC bool knobs
that map exactly onto the two-endpoint smart-HTTP flow, and transport.AdvertiseRefs
emits the # service=...\n + flush "smart reply" prefix when told to. So this is
almost entirely HTTP plumbing around the same backend the git:// daemon already uses.
Decisions from the user:
- No auth now — expose a plain
http.Handlerso auth can be added as wrapping middleware later. Push stays gated by the existing global-allow-pushflag. - Run both transports. HTTP is the new primary (
-http-bind, default on); git:// becomes opt-in via-git-bind(if unset, the git:// listener is not started). - Rename the existing git:// files to
git_protocol.go/git_protocol_test.go.
Code/test conventions follow the xe-go:xe-go-style and xe-go:go-table-driven-tests
skills (flagenv→flag, kebab-case flags, slog with "err" key, errgroup server
lifecycle, t.Helper() helpers, table-driven subtests with tt).
| Action | Path | Purpose |
|---|---|---|
| Rename | cmd/objgitd/daemon.go → cmd/objgitd/git_protocol.go |
git:// server (unchanged logic) |
| Rename | cmd/objgitd/daemon_test.go → cmd/objgitd/git_protocol_test.go |
git:// tests (unchanged) |
| Create | cmd/objgitd/http.go |
smart-HTTP handler on *daemon |
| Create | cmd/objgitd/http_test.go |
table-driven HTTP clone/push tests |
| Edit | cmd/objgitd/main.go |
flags + errgroup two-listener lifecycle |
The daemon struct stays the shared backend (fs, loader, allowPush,
loadOrInit). git:// methods live in git_protocol.go; HTTP methods in http.go.
Use git mv for the renames so history is preserved.
Make *daemon implement http.Handler. Dispatch on URL suffix (the same way
git-http-backend does), since repo paths are multi-segment and end in .git
(e.g. /foo/bar.git/info/refs) — http.ServeMux wildcards can't capture a
variable-depth prefix before a fixed suffix.
func (d *daemon) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case r.Method == http.MethodGet && strings.HasSuffix(p, "/info/refs"):
d.handleInfoRefs(w, r, strings.TrimSuffix(p, "/info/refs"))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-upload-pack"):
d.handleRPC(w, r, transport.UploadPackService, strings.TrimSuffix(p, "/git-upload-pack"))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-receive-pack"):
d.handleRPC(w, r, transport.ReceivePackService, strings.TrimSuffix(p, "/git-receive-pack"))
default:
http.NotFound(w, r)
}
}- Read
servicefrom the query; reject anything that isn't the two known services with400. - Resolve the repo, mirroring the git:// switch in
git_protocol.go:git-upload-pack:d.loader.Load(&url.URL{Path: repo}); ontransport.ErrRepositoryNotFound→404.git-receive-pack: if!d.allowPush→403; elsed.loadOrInit(repo)(preserves git://'s "first push creates the repo" behavior — the advertise GET happens before the POST, so it must create here too).
- Set headers before writing the body:
Content-Type: application/x-<service>-advertisementCache-Control: no-cache
- Emit the advertisement by calling the matching server command with
AdvertiseRefs: true, StatelessRPC: true(StatelessRPC=true makesAdvertiseRefswrite the# service=...smart-reply prefix). Reader isnil(the advertise path returns before touching it); wrap the writer withioutil.WriteNopCloser(w):(and thetransport.UploadPack(r.Context(), st, nil, ioutil.WriteNopCloser(w), &transport.UploadPackRequest{AdvertiseRefs: true, StatelessRPC: true, GitProtocol: r.Header.Get("Git-Protocol")})
ReceivePackequivalent for receive-pack).
- Resolve repo with the same load / loadOrInit / 403 / 404 rules as above.
- Body handling: if
Content-Encoding: gzip, wrapr.Bodyingzip.NewReader(git clients gzip the upload-pack request). Wrap the (possibly decompressed) body inio.NopCloserfor theio.ReadCloserarg. - Headers before body:
Content-Type: application/x-<service>-result,Cache-Control: no-cache. - Dispatch with
StatelessRPC: true(andAdvertiseRefs: false) so the command skips advertisement and reads the negotiation straight from the POST body:transport.UploadPack(r.Context(), st, body, ioutil.WriteNopCloser(w), &transport.UploadPackRequest{StatelessRPC: true, GitProtocol: r.Header.Get("Git-Protocol")})
- Pre-dispatch failures use real status codes; once the command starts writing,
errors are only
slog.Error(... "err", err)-logged (status already sent).
- Reuse
ioutil=github.com/go-git/go-git/v6/utils/ioutilforWriteNopCloser— no local nop-closer type needed. transport.UploadPackService/ReceivePackServiceare the string constants"git-upload-pack"/"git-receive-pack", so they match both the?service=value and the URL suffixes with no conversion friction.- Log each request with slog (service, repo path, remote) like the git://
handle.
- Replace the
bindflag:gitBind := flag.String("git-bind", "", "TCP address for the git:// protocol; empty disables it")httpBind := flag.String("http-bind", ":8080", "TCP address for the git smart-HTTP protocol; empty disables it")- Keep
-bucket,-allow-push,-slog-level.
- Validate at least one of
gitBind/httpBindis non-empty, elseslog.Error+ exit. - Replace the single
d.Serve(ctx, ln)call with anerrgroup.WithContext(ctx)(golang.org/x/sync/errgroup, already in go.mod — promote to direct viago mod tidy) running, conditionally:- git://: if
*gitBind != "",net.Listenthend.Serve(gCtx, ln). - HTTP: if
*httpBind != "", buildsrv := &http.Server{Handler: d}, runsrv.Serve(ln)in one goroutine and a second goroutine that waits ongCtx.Done()thensrv.Shutdown(context.WithTimeout(...10s)). Treathttp.ErrServerClosedas clean shutdown.
- git://: if
slog.Info("objgitd listening", "git_bind", *gitBind, "http_bind", *httpBind, "bucket", ...).
Same package, so reuse runGit/tryGit from git_protocol_test.go. Drive a real
git client against httptest.NewServer(d) over a memfs-backed daemon. Guard
with the existing exec.LookPath("git") skip.
Cases (one table, tt, subtests via t.Run(tt.name, ...)):
- push then clone round-trips (
allowPush: true): push a seed commit to<ts.URL>/test.git, assert/test.git/configexists in the memfs, clone it back,rev-parse HEADis non-empty. - push creates repo on demand (
allowPush: true): push to a path that does not exist yet succeeds and creates the bare repo (HTTP parity withTestDaemonPushCreatesRepo). - push rejected when disabled (
allowPush: false): push fails and/test.git/configis never created. - fetch from missing repo 404s: clone of a nonexistent path fails.
Per-case fields: name, allowPush bool, plus a wantErr-style expectation for
the push/clone outcome. Helper (e.g. newHTTPServer(t, allowPush) (*httptest.Server, billy.Filesystem))
marked t.Helper(). Note: httptest serves plain HTTP, so the git client needs
no TLS config; GIT_TERMINAL_PROMPT=0 is already set by tryGit.
gofmt/goimportsclean;go build ./....go test ./cmd/objgitd/...— both git:// and HTTP suites pass (git:// tests unchanged after rename).go mod tidyleavesgolang.org/x/syncas a direct dep, no other churn.- Manual smoke against a real bucket:
Optionally add
objgitd -bucket $BUCKET -http-bind :8080 -allow-push git clone http://localhost:8080/smoke.git # 404/empty until first push (cd repo && git push http://localhost:8080/smoke.git main) # creates + pushes git clone http://localhost:8080/smoke.git verify # round-trips-git-bind :9418and confirm both transports serve the same repo.