# git-remote-entire: stamp HTTP User-Agent on outbound requests

The helper already advertises itself via the git protocol pkt-line agent=
capability (commit-stamped), but every HTTP request it makes — info/refs,
upload-pack/receive-pack POSTs, well-known discovery, STS exchanges,
refresh-token rotations — went out with Go's default "Go-http-client/1.1",
making the helper invisible in upstream access logs and indistinguishable
from any other Go client hitting the data plane.

Add httpclient.UserAgentTransport, a small RoundTripper wrapper that
stamps User-Agent on each outbound request, and wire it into both the
auth-side *http.Client and the transport.Proxy so every credentialled hop
from the helper carries "git-remote-entire/<version>". The wrapper sits
outside the httpdebug RoundTripper so the debug log captures the same
headers the wire sees. UserAgent on transport.Config is optional — empty
falls back to Go's default, which keeps the existing tests that don't
care about identity working unchanged.

Format is version-stamped (binary/version) to match the conventional form
the rest of the entire HTTP clients use; the git protocol agent stays
commit-stamped where it is, since the two serve different correlation
needs and changing it risks breaking server-side log analysis.

Assisted-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>

## Sessions

## Changes

5

- cmd/git-remote-entire
  - Mmain.go+14/-3
- internal
  - entireclient/httpclient
    - Auseragent.go+28
    - Auseragent_test.go+100
  - remotehelper/transport
    - Mproxy.go+16/-2

```go
package httpclient

import "net/http"

// UserAgentTransport wraps another http.RoundTripper and stamps the
// User-Agent header on every outgoing request so the server can
// attribute traffic to the calling binary. Callers that already set
// User-Agent are overwritten: the wrapper exists to give the binary a
// single identity in upstream access logs, not to be overridden per
// request.

// The wrapper clones the request before mutating headers so the
// caller's original *http.Request is left untouched — important for
// retries and for callers that hold a reference after Do returns.

// Concurrent use is safe iff Next is safe for concurrent use.
type UserAgentTransport struct {
	Next http.RoundTripper
	UA   string
}

// RoundTrip implements http.RoundTripper.
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	r := req.Clone(req.Context())
	r.Header.Set("User-Agent", t.UA)
	return t.Next.RoundTrip(r)
}
```

```go
func TestUserAgentTransport_SetsHeader(t *testing.T) {
	t.Parallel()

var got string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = r.Header.Get("User-Agent")
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(srv.Close)

client := &http.Client{
		Transport: &UserAgentTransport{
			Next: http.DefaultTransport,
			UA:   "test-binary/1.2.3",
		},
	}
	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
	if err != nil {
		t.Fatalf("NewRequest: %v", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Do: %v", err)
	}
	_ = resp.Body.Close()

if want := "test-binary/1.2.3"; got != want {
		t.Errorf("User-Agent = %q, want %q", got, want)
	}
}
```

```go
func TestUserAgentTransport_OverwritesCallerHeader(t *testing.T) {
	t.Parallel()

client := &http.Client{
		Transport: &UserAgentTransport{
			Next: http.DefaultTransport,
			UA:   "wrapper-set",
		},
	}
	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
	if err != nil {
		t.Fatalf("NewRequest: %v", err)
	}
	req.Header.Set("User-Agent", "caller-set")
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Do: %v", err)
	}
	_ = resp.Body.Close()

if want := "wrapper-set"; got != want {
		t.Errorf("User-Agent = %q, want %q", got, want)
	}
}
```

```go
func TestUserAgentTransport_DoesNotMutateCallerRequest(t *testing.T) {
	t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(srv.Close)

if got := req.Header.Get("User-Agent"); got != "caller-set" {
		t.Errorf("caller request mutated: User-Agent = %q, want %q", got, "caller-set")
	}
}
```

```go
func TestProxy_SetsUserAgentHeader(t *testing.T) {
	t.Parallel()

var got string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = r.Header.Get("User-Agent")
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(server.Close)

p := New(Config{
		Nodes: replicas.NodeConfig{
			InitialNodes: []string{server.URL},
			EntryURL:     server.URL,
			ClusterHost:  mustHost(t, server.URL),
			RepoPath:     "owner/repo",
		},
		Path:      "/et/owner/repo",
		UserAgent: "git-remote-entire/9.9.9",
	})

resp, err := p.InfoRefs(t.Context(), serviceParam)
	if err != nil {
		t.Fatalf("InfoRefs: %v", err)
	}
	_ = resp.Close()

if want := "git-remote-entire/9.9.9"; got != want {
		t.Errorf("User-Agent = %q, want %q", got, want)
	}
}
```

```go
func TestProxy_OmitsUserAgentWrapperWhenEmpty(t *testing.T) {
	t.Parallel()

p := New(Config{
		Nodes: replicas.NodeConfig{
			InitialNodes: []string{server.URL},
			EntryURL:     server.URL,
			ClusterHost:  mustHost(t, server.URL),
			RepoPath:     "owner/repo",
		},
		Path: "/et/owner/repo",
	})

resp, err := p.InfoRefs(t.Context(), serviceParam)
	if err != nil {
		t.Fatalf("InfoRefs: %v", err)
	}
	_ = resp.Close()

if !strings.HasPrefix(got, "Go-http-client/") {
		t.Errorf("User-Agent = %q, want Go's default when wrapper omitted", got)
	}
}
```
