serve .git files raw so dumb-HTTP clone actually works
commit 3d0af8c57e51374cfd84d486fdf1f01051d5c9d8 · parent 538bf4237f
· browse this tree
Ximianth, 2026-07-30 00:00:44 UTC
expose-git's whole purpose is making `git clone .../.git` work, but it never did: git fetches info/refs, HEAD, and objects as plain GETs (no query string), and serveFromTree routed every non-?fmt=raw file through serveRendered, which wraps the bytes in an HTML page. Git received HTML and bailed with "not valid: is this a git repository?". update-server-info was doing its job (info/refs on disk was fresh); the break was purely the serve path dressing those files as browsable pages. Fix: any path with a .git segment serves raw. Reaching that branch already implies the exhibit opted in via expose-git (the non-exposed case is rejected at the top of serveFromTree), and rendering .git contents as pages was never meaningful. The prior .git tests all appended ?fmt=raw, so they exercised the raw path git never uses and missed this. Added two that hit the real path: one asserts info/refs serves un-wrapped, and one does a full `git clone` over httptest and checks the clone lands on the source tip.
diff --git a/serve.go b/serve.go index cdb03d6..f4c3d74 100644 --- a/serve.go +++ b/serve.go @@ -214,6 +214,14 @@ func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree, s.serveDir(w, r, tr, mount, rel) case r.URL.Query().Get("fmt") == "raw": serveRaw(w, r, root, rel) + case hasGitSegment(rel): + // Files inside an exposed .git serve raw always: git's dumb-HTTP + // client fetches info/refs, HEAD, objects, etc. as plain GETs with + // no query string, and chokes on the HTML-wrapped rendered view. + // (Reaching here at all means the exhibit opted in via expose-git; + // the non-exposed case was rejected at the top of serveFromTree.) + // Rendering .git contents as pages was never meaningful anyway. + serveRaw(w, r, root, rel) default: serveRendered(w, r, root, mount, rel, info.Size()) } diff --git a/serve_test.go b/serve_test.go index 5ac9140..62e2cd1 100644 --- a/serve_test.go +++ b/serve_test.go @@ -252,3 +252,58 @@ func TestExposeGitRunsUpdateServerInfo(t *testing.T) { t.Errorf("info/refs: status = %d, body = %q", rec.Code, rec.Body.String()) } } + +// TestExposeGitFilesServeRaw pins the fix for the dumb-HTTP transport: +// git fetches .git files as plain GETs with no query string, so those +// paths must serve raw bytes, not the HTML-wrapped rendered view. +// (The older .git tests all appended ?fmt=raw and so never caught this.) +func TestExposeGitFilesServeRaw(t *testing.T) { + srv, _ := newExposeGitTestServer(t) + rec := get(t, srv, "/repo/.git/info/refs") // no ?fmt=raw, as git itself sends + if rec.Code != http.StatusOK { + t.Fatalf("info/refs: status = %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "<!doctype html>") { + t.Errorf("info/refs served as HTML wrapper; git cannot parse this:\n%s", body) + } + if !strings.Contains(body, "refs/heads/") { + t.Errorf("info/refs missing ref listing; body = %q", body) + } +} + +// TestExposeGitCloneWorks is the end-to-end proof: a real `git clone` over +// HTTP against a live server must succeed and land on the actual tip commit. +// This is the behavior expose-git exists to provide. +func TestExposeGitCloneWorks(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + srv, srcDir := newExposeGitTestServer(t) + ts := httptest.NewServer(srv) + defer ts.Close() + + dst := filepath.Join(t.TempDir(), "clone") + cmd := exec.Command("git", "clone", "-q", ts.URL+"/repo/.git", dst) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git clone failed: %v: %s", err, out) + } + + // The clone must reflect the source repo's actual tip, not a stale/empty one. + want := gitRevParse(t, srcDir, "HEAD") + got := gitRevParse(t, dst, "HEAD") + if got != want { + t.Errorf("clone tip = %s, want %s", got, want) + } +} + +func gitRevParse(t *testing.T, dir, rev string) string { + t.Helper() + cmd := exec.Command("git", "rev-parse", rev) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git rev-parse %s in %s: %v: %s", rev, dir, err, out) + } + return strings.TrimSpace(string(out)) +}