diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index a6b0378..e1f84e1 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -14,9 +14,11 @@ package githttp
import (
"errors"
"fmt"
+ "io"
"log/slog"
"net/http"
"net/http/cgi"
+ "os"
"os/exec"
"strings"
@@ -184,6 +186,22 @@ func (h *Handler) materialize(w http.ResponseWriter, r *http.Request, repoName s
// path (empty CGI Root) and GIT_PROJECT_ROOT is the cache root, so http-backend
// resolves the repo dir the materializer just wrote.
func (h *Handler) serveCGI(w http.ResponseWriter, r *http.Request, extraEnv []string) {
+ // git http-backend runs via CGI and needs CONTENT_LENGTH. A request with an
+ // unknown length — chunked transfer-encoding, which git uses for a pack larger
+ // than the client's http.postBuffer (default 1 MiB) — carries none, and
+ // http-backend rejects it with 400. Buffer such a body to a temp file to give
+ // it a fixed length before handing off, so a large push works with no client
+ // http.postBuffer tuning. Bodies with a known length stream through unchanged.
+ if r.Body != nil && r.ContentLength < 0 {
+ cleanup, err := bufferBodyToFile(r)
+ if err != nil {
+ h.logger.Error("buffering request body", "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ defer cleanup()
+ }
+
env := append([]string{
"GIT_PROJECT_ROOT=" + h.mz.Root(),
"GIT_HTTP_EXPORT_ALL=1",
@@ -197,6 +215,33 @@ func (h *Handler) serveCGI(w http.ResponseWriter, r *http.Request, extraEnv []st
backend.ServeHTTP(w, r)
}
+// bufferBodyToFile drains r.Body to a temp file and rewires the request to read
+// from it with a known ContentLength, turning a length-unknown (chunked) body
+// into a fixed-length one for the CGI child. It returns a cleanup that closes and
+// removes the file after the response is served. Spilling to disk (not memory)
+// keeps a large pack off the heap.
+func bufferBodyToFile(r *http.Request) (func(), error) {
+ f, err := os.CreateTemp("", "gitmote-body-*")
+ if err != nil {
+ return nil, err
+ }
+ cleanup := func() { _ = f.Close(); _ = os.Remove(f.Name()) }
+ n, err := io.Copy(f, r.Body)
+ _ = r.Body.Close()
+ if err != nil {
+ cleanup()
+ return nil, err
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ cleanup()
+ return nil, err
+ }
+ r.Body = f
+ r.ContentLength = n
+ r.TransferEncoding = nil
+ return cleanup, nil
+}
+
// writeAuthError maps an Authorize error to the HTTP response git expects: a
// 401 carries the Basic challenge so git (and its credential helpers) retry
// with credentials; a 403 is a hard stop; a missing repo is a 404.
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index c594f27..cd3e0a0 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -2,6 +2,7 @@ package githttp
import (
"context"
+ "crypto/rand"
"errors"
"fmt"
"log/slog"
@@ -182,6 +183,47 @@ func TestWriteGateFollowerRefusesPush(t *testing.T) {
}
}
+// TestPushChunkedLargePack pushes a pack larger than git's default
+// http.postBuffer (1 MiB) with the client at its defaults, so git sends the body
+// with chunked transfer-encoding (no Content-Length). The server must buffer it
+// to a length before the CGI hand-off; without that, git http-backend 400s.
+func TestPushChunkedLargePack(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s, _ := newWriteServer(t)
+
+ r, err := m.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // A 3 MiB incompressible blob → a pack well over the 1 MiB postBuffer, so the
+ // client streams it chunked (the harness git uses default config: no override).
+ src := t.TempDir()
+ git(t, src, "init", "-b", "main", ".")
+ big := make([]byte, 3<<20)
+ if _, err := rand.Read(big); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "big.bin"), big, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "add", ".")
+ git(t, src, "commit", "-m", "big")
+ head := git(t, src, "rev-parse", "HEAD")
+
+ git(t, src, "push", remote, "main") // fails (HTTP 400) before the server-side buffering
+
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
+ t.Fatalf("server main = %q, want %q", got, head)
+ }
+ objs, _ := s.List(ctx, repoName+"/objects/")
+ if len(objs) == 0 {
+ t.Fatal("no objects uploaded after chunked push")
+ }
+}
+
// tryGit runs a git command hermetically and returns its combined output and
// error without failing the test — for operations expected to be rejected.
func tryGit(dir string, args ...string) (string, error) {