···11+# Plan: Git Smart HTTP protocol for objgitd
22+33+## Context
44+55+`objgitd` currently serves repositories out of a Tigris/S3-backed billy filesystem
66+over the **git:// (TCP)** protocol only (`cmd/objgitd/daemon.go`). git:// is
77+unauthenticated, hard to put behind a normal HTTP proxy, and not what most hosts
88+(or CI systems) expect. We want clients to be able to `clone`/`fetch`/`push` over
99+**HTTP smart protocol** (`https://host/repo.git`), which is the standard transport
1010+and the natural place to bolt on auth later via middleware.
1111+1212+go-git v6's server commands are already HTTP-ready: `transport.UploadPack` and
1313+`transport.ReceivePack` expose `AdvertiseRefs bool` and `StatelessRPC bool` knobs
1414+that map exactly onto the two-endpoint smart-HTTP flow, and `transport.AdvertiseRefs`
1515+emits the `# service=...\n` + flush "smart reply" prefix when told to. So this is
1616+almost entirely HTTP plumbing around the same backend the git:// daemon already uses.
1717+1818+Decisions from the user:
1919+2020+- **No auth now** — expose a plain `http.Handler` so auth can be added as wrapping
2121+ middleware later. Push stays gated by the existing global `-allow-push` flag.
2222+- **Run both transports.** HTTP is the new primary (`-http-bind`, default on);
2323+ git:// becomes opt-in via `-git-bind` (if unset, the git:// listener is not started).
2424+- **Rename** the existing git:// files to `git_protocol.go` / `git_protocol_test.go`.
2525+2626+Code/test conventions follow the `xe-go:xe-go-style` and `xe-go:go-table-driven-tests`
2727+skills (flagenv→flag, kebab-case flags, slog with `"err"` key, errgroup server
2828+lifecycle, `t.Helper()` helpers, table-driven subtests with `tt`).
2929+3030+## Files
3131+3232+| Action | Path | Purpose |
3333+| ------ | ----------------------------------------------------------------- | --------------------------------------- |
3434+| Rename | `cmd/objgitd/daemon.go` → `cmd/objgitd/git_protocol.go` | git:// server (unchanged logic) |
3535+| Rename | `cmd/objgitd/daemon_test.go` → `cmd/objgitd/git_protocol_test.go` | git:// tests (unchanged) |
3636+| Create | `cmd/objgitd/http.go` | smart-HTTP handler on `*daemon` |
3737+| Create | `cmd/objgitd/http_test.go` | table-driven HTTP clone/push tests |
3838+| Edit | `cmd/objgitd/main.go` | flags + errgroup two-listener lifecycle |
3939+4040+The `daemon` struct stays the shared backend (`fs`, `loader`, `allowPush`,
4141+`loadOrInit`). git:// methods live in `git_protocol.go`; HTTP methods in `http.go`.
4242+Use `git mv` for the renames so history is preserved.
4343+4444+## http.go design
4545+4646+Make `*daemon` implement `http.Handler`. Dispatch on URL **suffix** (the same way
4747+`git-http-backend` does), since repo paths are multi-segment and end in `.git`
4848+(e.g. `/foo/bar.git/info/refs`) — `http.ServeMux` wildcards can't capture a
4949+variable-depth prefix before a fixed suffix.
5050+5151+```go
5252+func (d *daemon) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5353+ p := r.URL.Path
5454+ switch {
5555+ case r.Method == http.MethodGet && strings.HasSuffix(p, "/info/refs"):
5656+ d.handleInfoRefs(w, r, strings.TrimSuffix(p, "/info/refs"))
5757+ case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-upload-pack"):
5858+ d.handleRPC(w, r, transport.UploadPackService, strings.TrimSuffix(p, "/git-upload-pack"))
5959+ case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-receive-pack"):
6060+ d.handleRPC(w, r, transport.ReceivePackService, strings.TrimSuffix(p, "/git-receive-pack"))
6161+ default:
6262+ http.NotFound(w, r)
6363+ }
6464+}
6565+```
6666+6767+### Reference discovery — `GET /{repo}/info/refs?service=git-(upload|receive)-pack`
6868+6969+- Read `service` from the query; reject anything that isn't the two known services
7070+ with `400`.
7171+- Resolve the repo, mirroring the git:// switch in `git_protocol.go`:
7272+ - `git-upload-pack`: `d.loader.Load(&url.URL{Path: repo})`; on
7373+ `transport.ErrRepositoryNotFound` → `404`.
7474+ - `git-receive-pack`: if `!d.allowPush` → `403`; else `d.loadOrInit(repo)`
7575+ (preserves git://'s "first push creates the repo" behavior — the advertise GET
7676+ happens before the POST, so it must create here too).
7777+- Set headers **before** writing the body:
7878+ - `Content-Type: application/x-<service>-advertisement`
7979+ - `Cache-Control: no-cache`
8080+- Emit the advertisement by calling the matching server command with
8181+ `AdvertiseRefs: true, StatelessRPC: true` (StatelessRPC=true makes
8282+ `AdvertiseRefs` write the `# service=...` smart-reply prefix). Reader is `nil`
8383+ (the advertise path returns before touching it); wrap the writer with
8484+ `ioutil.WriteNopCloser(w)`:
8585+ ```go
8686+ transport.UploadPack(r.Context(), st, nil, ioutil.WriteNopCloser(w),
8787+ &transport.UploadPackRequest{AdvertiseRefs: true, StatelessRPC: true,
8888+ GitProtocol: r.Header.Get("Git-Protocol")})
8989+ ```
9090+ (and the `ReceivePack` equivalent for receive-pack).
9191+9292+### RPC — `POST /{repo}/git-(upload|receive)-pack`
9393+9494+- Resolve repo with the same load / loadOrInit / 403 / 404 rules as above.
9595+- Body handling: if `Content-Encoding: gzip`, wrap `r.Body` in `gzip.NewReader`
9696+ (git clients gzip the upload-pack request). Wrap the (possibly decompressed)
9797+ body in `io.NopCloser` for the `io.ReadCloser` arg.
9898+- Headers before body: `Content-Type: application/x-<service>-result`,
9999+ `Cache-Control: no-cache`.
100100+- Dispatch with `StatelessRPC: true` (and `AdvertiseRefs: false`) so the command
101101+ skips advertisement and reads the negotiation straight from the POST body:
102102+ ```go
103103+ transport.UploadPack(r.Context(), st, body, ioutil.WriteNopCloser(w),
104104+ &transport.UploadPackRequest{StatelessRPC: true,
105105+ GitProtocol: r.Header.Get("Git-Protocol")})
106106+ ```
107107+- Pre-dispatch failures use real status codes; once the command starts writing,
108108+ errors are only `slog.Error(... "err", err)`-logged (status already sent).
109109+110110+### Helpers / notes
111111+112112+- Reuse `ioutil` = `github.com/go-git/go-git/v6/utils/ioutil` for `WriteNopCloser`
113113+ — no local nop-closer type needed.
114114+- `transport.UploadPackService`/`ReceivePackService` are the string constants
115115+ `"git-upload-pack"`/`"git-receive-pack"`, so they match both the `?service=`
116116+ value and the URL suffixes with no conversion friction.
117117+- Log each request with slog (service, repo path, remote) like the git:// `handle`.
118118+119119+## main.go changes
120120+121121+- Replace the `bind` flag:
122122+ - `gitBind := flag.String("git-bind", "", "TCP address for the git:// protocol; empty disables it")`
123123+ - `httpBind := flag.String("http-bind", ":8080", "TCP address for the git smart-HTTP protocol; empty disables it")`
124124+ - Keep `-bucket`, `-allow-push`, `-slog-level`.
125125+- Validate at least one of `gitBind`/`httpBind` is non-empty, else `slog.Error` + exit.
126126+- Replace the single `d.Serve(ctx, ln)` call with an `errgroup.WithContext(ctx)`
127127+ (`golang.org/x/sync/errgroup`, already in go.mod — promote to direct via
128128+ `go mod tidy`) running, conditionally:
129129+ - **git://**: if `*gitBind != ""`, `net.Listen` then `d.Serve(gCtx, ln)`.
130130+ - **HTTP**: if `*httpBind != ""`, build `srv := &http.Server{Handler: d}`, run
131131+ `srv.Serve(ln)` in one goroutine and a second goroutine that waits on
132132+ `gCtx.Done()` then `srv.Shutdown(context.WithTimeout(...10s))`. Treat
133133+ `http.ErrServerClosed` as clean shutdown.
134134+- `slog.Info("objgitd listening", "git_bind", *gitBind, "http_bind", *httpBind, "bucket", ...)`.
135135+136136+## Tests — http_test.go (table-driven)
137137+138138+Same package, so reuse `runGit`/`tryGit` from `git_protocol_test.go`. Drive a real
139139+git client against `httptest.NewServer(d)` over a `memfs`-backed `daemon`. Guard
140140+with the existing `exec.LookPath("git")` skip.
141141+142142+Cases (one table, `tt`, subtests via `t.Run(tt.name, ...)`):
143143+144144+- **push then clone round-trips** (`allowPush: true`): push a seed commit to
145145+ `<ts.URL>/test.git`, assert `/test.git/config` exists in the memfs, clone it
146146+ back, `rev-parse HEAD` is non-empty.
147147+- **push creates repo on demand** (`allowPush: true`): push to a path that does
148148+ not exist yet succeeds and creates the bare repo (HTTP parity with
149149+ `TestDaemonPushCreatesRepo`).
150150+- **push rejected when disabled** (`allowPush: false`): push fails and
151151+ `/test.git/config` is never created.
152152+- **fetch from missing repo 404s**: clone of a nonexistent path fails.
153153+154154+Per-case fields: `name`, `allowPush bool`, plus a `wantErr`-style expectation for
155155+the push/clone outcome. Helper (e.g. `newHTTPServer(t, allowPush) (*httptest.Server, billy.Filesystem)`)
156156+marked `t.Helper()`. Note: `httptest` serves plain HTTP, so the git client needs
157157+no TLS config; `GIT_TERMINAL_PROMPT=0` is already set by `tryGit`.
158158+159159+## Verification
160160+161161+1. `gofmt`/`goimports` clean; `go build ./...`.
162162+2. `go test ./cmd/objgitd/...` — both git:// and HTTP suites pass (git:// tests
163163+ unchanged after rename).
164164+3. `go mod tidy` leaves `golang.org/x/sync` as a direct dep, no other churn.
165165+4. Manual smoke against a real bucket:
166166+ ```
167167+ objgitd -bucket $BUCKET -http-bind :8080 -allow-push
168168+ git clone http://localhost:8080/smoke.git # 404/empty until first push
169169+ (cd repo && git push http://localhost:8080/smoke.git main) # creates + pushes
170170+ git clone http://localhost:8080/smoke.git verify # round-trips
171171+ ```
172172+ Optionally add `-git-bind :9418` and confirm both transports serve the same repo.
173173+174174+```
175175+176176+```