Skip to content

Commit 39e0654

Browse files
nodoclaude
andcommitted
smarthttp: optionally follow info/refs redirects into Endpoint
Adds Conn.FollowInfoRefsRedirect (off by default). When set, RequestInfoRefs rewrites Endpoint.Scheme + Endpoint.Host to the final URL after HTTP redirects, so subsequent PostRPC* calls target the redirected node instead of the originally-configured entry point. Matches vanilla git's smart-HTTP behaviour for discovery-aware servers that 307 /info/refs to a hosting replica (the entiredb info/refs discovery design). Endpoint.Path is never modified — it still holds the repo path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 359b871b7cfb
1 parent a9a36b4 commit 39e0654

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

internal/gitproto/smarthttp.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ type Conn struct {
4545
Transport transport.Transport
4646
HTTP *http.Client
4747
Auth transport.AuthMethod
48+
49+
// FollowInfoRefsRedirect, when true, rewrites Endpoint.Scheme and
50+
// Endpoint.Host to the final URL returned by RequestInfoRefs after
51+
// HTTP redirects. Subsequent PostRPC* calls then target the
52+
// redirected host directly, matching vanilla git's smart-HTTP
53+
// behaviour for discovery-aware servers that 307 /info/refs to a
54+
// hosting replica. Endpoint.Path is never modified — it still
55+
// contains the repo path. Off by default to preserve behaviour for
56+
// callers that rely on Endpoint being stable.
57+
FollowInfoRefsRedirect bool
4858
}
4959

5060
// NewConn creates a new connection to the given endpoint.
@@ -109,6 +119,13 @@ func RequestInfoRefs(ctx context.Context, conn *Conn, service transport.Service,
109119
if err := httpError(res); err != nil {
110120
return nil, err
111121
}
122+
if conn.FollowInfoRefsRedirect && res.Request != nil && res.Request.URL != nil {
123+
final := res.Request.URL
124+
if final.Host != conn.Endpoint.Host || final.Scheme != conn.Endpoint.Scheme {
125+
conn.Endpoint.Scheme = final.Scheme
126+
conn.Endpoint.Host = final.Host
127+
}
128+
}
112129
// Bound the read to prevent unbounded memory allocation (issue #9).
113130
const maxInfoRefsSize = 64 * 1024 * 1024 // 64 MiB
114131
lr := io.LimitReader(res.Body, maxInfoRefsSize+1)

internal/gitproto/smarthttp_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"io"
77
"net/http"
8+
"net/http/httptest"
9+
"strings"
810
"testing"
911
"time"
1012

@@ -170,6 +172,71 @@ func TestPostRPCStreamContextCanceled(t *testing.T) {
170172
}
171173
}
172174

175+
// TestRequestInfoRefs_FollowInfoRefsRedirect verifies that when the flag is
176+
// set, a 307 on /info/refs rewrites Conn.Endpoint.Host so subsequent PostRPC
177+
// calls target the redirected node. Matches vanilla git's smart-HTTP
178+
// behaviour and lets clients use a cluster entry domain for info/refs while
179+
// packs land on the hosting replica.
180+
func TestRequestInfoRefs_FollowInfoRefsRedirect(t *testing.T) {
181+
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182+
w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
183+
_, _ = w.Write([]byte("001e# service=git-upload-pack\n0000"))
184+
}))
185+
defer node.Close()
186+
187+
entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
188+
http.Redirect(w, r, node.URL+r.URL.Path+"?"+r.URL.RawQuery, http.StatusTemporaryRedirect)
189+
}))
190+
defer entry.Close()
191+
192+
ep, err := transport.NewEndpoint(entry.URL + "/repo.git")
193+
if err != nil {
194+
t.Fatalf("parse endpoint: %v", err)
195+
}
196+
conn := NewConn(ep, "test", nil, http.DefaultTransport)
197+
conn.FollowInfoRefsRedirect = true
198+
199+
if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
200+
t.Fatalf("RequestInfoRefs: %v", err)
201+
}
202+
203+
nodeURL := strings.TrimPrefix(node.URL, "http://")
204+
if conn.Endpoint.Host != nodeURL {
205+
t.Errorf("Endpoint.Host = %q, want %q (endpoint should follow the 307)", conn.Endpoint.Host, nodeURL)
206+
}
207+
}
208+
209+
// TestRequestInfoRefs_DoesNotFollowByDefault confirms the default behaviour
210+
// is unchanged: Endpoint is stable even if the server 307s.
211+
func TestRequestInfoRefs_DoesNotFollowByDefault(t *testing.T) {
212+
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213+
w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
214+
_, _ = w.Write([]byte("001e# service=git-upload-pack\n0000"))
215+
}))
216+
defer node.Close()
217+
218+
entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
219+
http.Redirect(w, r, node.URL+r.URL.Path+"?"+r.URL.RawQuery, http.StatusTemporaryRedirect)
220+
}))
221+
defer entry.Close()
222+
223+
ep, err := transport.NewEndpoint(entry.URL + "/repo.git")
224+
if err != nil {
225+
t.Fatalf("parse endpoint: %v", err)
226+
}
227+
entryHost := ep.Host
228+
conn := NewConn(ep, "test", nil, http.DefaultTransport)
229+
// FollowInfoRefsRedirect intentionally not set.
230+
231+
if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
232+
t.Fatalf("RequestInfoRefs: %v", err)
233+
}
234+
235+
if conn.Endpoint.Host != entryHost {
236+
t.Errorf("Endpoint.Host = %q, want %q (endpoint should be unchanged by default)", conn.Endpoint.Host, entryHost)
237+
}
238+
}
239+
173240
func TestHTTPErrorBoundsBodyRead(t *testing.T) {
174241
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com/repo.git", nil)
175242
if err != nil {

0 commit comments

Comments
 (0)