vitrine

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))
+}