# Add HTTP client injection to gitsync

`cd93f12`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

Soph·3mo ago·7 files·+101 added/-31 removed

## Sessions

15ba661d1843View transcript

## Changes

7

- internal

- gitproto

- Msmarthttp.go+8

- syncer

- Mauth_test.go+22/-2

- Mgit_http_backend_test.go+5/-5

- Msyncer.go+26/-12

- Msyncer_test.go+2/-3

- pkg/gitsync

- Mclient.go+19/-8

- Mclient_test.go+19/-1

```
49 unmodified lines

50
51
52
53
54
55
56
57
58
59
60
61
62
63

49 unmodified lines

// NewConn creates a new connection to the given endpoint.
func NewConn(ep *transport.Endpoint, label string, auth transport.AuthMethod, rt http.RoundTripper) *Conn {
	httpClient := &http.Client{Transport: rt}
	return NewConnWithHTTPClient(ep, label, auth, httpClient)
}

// NewConnWithHTTPClient creates a new connection using the provided HTTP client.
func NewConnWithHTTPClient(ep *transport.Endpoint, label string, auth transport.AuthMethod, httpClient *http.Client) *Conn {
	if httpClient == nil {
		httpClient = &http.Client{Transport: http.DefaultTransport}
	}
	return &Conn{
		Label:     label,
		Endpoint:  ep,
	}
}

Minternal/gitproto/smarthttp.go+8

```
51 unmodified lines

52
53
54
55
55
56
57
58
10 unmodified lines

69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
97 unmodified lines

193
194
195
175

51 unmodified lines

conn, err := newConn(Endpoint{
		URL:           "https://example.com/repo.git",
		SkipTLSVerify: true,
	}, "source", stats)
	}, "source", stats, nil)
	if err != nil {
		t.Fatalf("new conn: %v", err)
	}
	10 unmodified lines

}
}

func TestNewConnUsesProvidedHTTPClient(t *testing.T) {
	stats := newStats(false)
	baseTransport := http.DefaultTransport
	baseClient := &http.Client{Transport: baseTransport}

conn, err := newConn(Endpoint{URL: "https://example.com/repo.git"}, "source", stats, baseClient)
	if err != nil {
		t.Fatalf("new conn: %v", err)
	}
	if conn.HTTP == baseClient {
		t.Fatalf("expected cloned HTTP client, got original pointer")
	}
	rt, ok := conn.HTTP.Transport.(*countingRoundTripper)
	if !ok {
		t.Fatalf("expected countingRoundTripper, got %T", conn.HTTP.Transport)
	}
	if rt.base != baseTransport {
		t.Fatalf("wrapped base transport = %T, want %T", rt.base, baseTransport)
	}
}

func TestResolveAuthMethodUsesEntireDBStoredToken(t *testing.T) {
	configDir := t.TempDir()
	tokenStorePath := filepath.Join(t.TempDir(), "tokens.json")

97 unmodified lines

t.Fatalf("write hosts: %v", err)
	}
}

```

Minternal/syncer/auth_test.go+22/-2

```
514 unmodified lines

515
516
517
518
518
519
520
521
90 unmodified lines

612
613
614
615
615
616
617
618
13 unmodified lines

632
633
634
635
635
636
637
638
637
638
639
640
641

514 unmodified lines

}

stats := newStats(false)
	sourceConn, err := newConn(cfg.Source, "source", stats)
	sourceConn, err := newConn(cfg.Source, "source", stats, nil)
	if err != nil {
		t.Fatalf("create source transport: %v", err)
	}
	90 unmodified lines

}

stats := newStats(false)
	sourceConn, err := newConn(cfg.Source, "source", stats)
	sourceConn, err := newConn(cfg.Source, "source", stats, nil)
	if err != nil {
		t.Fatalf("create source transport: %v", err)
	}
	13 unmodified lines

plan := func(limit int64) []plumbing.Hash {
		t.Helper()
		checkpoints, err := bstrap.PlanCheckpoints(context.Background(), bstrap.Params{
			SourceConn:   sourceConn,
			SourceConn:    sourceConn,
			SourceService: sourceService,
			BatchMaxPack: limit,
			Verbose:      cfg.Verbose,
			BatchMaxPack:  limit,
			Verbose:       cfg.Verbose,
		}, ref)
		if err != nil {
			t.Fatalf("plan checkpoints with limit %d: %v", limit, err)
	}
}

Minternal/syncer/git_http_backend_test.go+5/-5

```

8 unmodified lines

9
10
11
12
13
14
15
39 unmodified lines

55
56
57
58
59
60
61
211 unmodified lines

273
274
275
274
276
277
278
279
8 unmodified lines

288
289
290
289
290
291
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
22 unmodified lines

331
332
333
320
321
322
323
324
325
334
335
336
337
338
339
340
341
342
19 unmodified lines

362
363
364
351
365
366
367
368
7 unmodified lines

376
377
378
365
379
380
381
382

8 unmodified lines

"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"sort"
	"strings"
39 unmodified lines

type Config struct {
		Source                 Endpoint
		Target                 Endpoint
		HTTPClient             *http.Client
		Branches               []string
		Mappings               []RefMapping
		IncludeTags            bool
211 unmodified lines

// --- Session setup ---

func newConn(raw Endpoint, label string, stats *statsCollector) (*gitproto.Conn, error) {
func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http.Client) (*gitproto.Conn, error) {
	ep, err := transport.NewEndpoint(raw.URL)
	if err != nil {
		return nil, err
	}
8 unmodified lines

if err != nil {
		return nil, err
	}
	baseRT := gitproto.NewHTTPTransport(raw.SkipTLSVerify)
	rt := &countingRoundTripper{base: baseRT, label: label, stats: stats}
	return gitproto.NewConn(ep, label, authMethod, rt), nil
	client := instrumentHTTPClient(httpClient, raw.SkipTLSVerify, label, stats)
	return gitproto.NewConnWithHTTPClient(ep, label, authMethod, client), nil
}

func instrumentHTTPClient(base *http.Client, skipTLS bool, label string, stats *statsCollector) *http.Client {
	if base == nil {
		base = &http.Client{Transport: gitproto.NewHTTPTransport(skipTLS)}
	}
	clone := *base
	baseRT := clone.Transport
	if baseRT == nil {
		baseRT = gitproto.NewHTTPTransport(skipTLS)
	}
	clone.Transport = &countingRoundTripper{base: baseRT, label: label, stats: stats}
	return &clone
}

func planConfig(cfg Config) planner.PlanConfig {
22 unmodified lines

}

type targetSession struct {
	conn      *gitproto.Conn
	adv       *packp.AdvRefs
	refMap    map[plumbing.ReferenceName]plumbing.Hash
	features  gitproto.TargetFeatures
	policy    planner.RelayTargetPolicy
	pusher    gitproto.Pusher
	conn     *gitproto.Conn
	adv      *packp.AdvRefs
	refMap   map[plumbing.ReferenceName]plumbing.Hash
	features gitproto.TargetFeatures
	policy   planner.RelayTargetPolicy
	pusher   gitproto.Pusher
}

// newSession performs the shared setup: protocol validation, mapping validation,
19 unmodified lines

}))

s.sourceConn, err = newConn(cfg.Source, "source", s.stats)
	s.sourceConn, err = newConn(cfg.Source, "source", s.stats, cfg.HTTPClient)
	if err != nil {
		return nil, fmt.Errorf("create source transport: %w", err)
	}
7 unmodified lines

s.sourceRefMap = gitproto.RefHashMap(sourceRefs)

if needTarget {
		targetConn, err := newConn(cfg.Target, "target", s.stats)
		targetConn, err := newConn(cfg.Target, "target", s.stats, cfg.HTTPClient)
		if err != nil {
			return nil, fmt.Errorf("create target transport: %w", err)
		}
}

Minternal/syncer/syncer.go+26/-12

```

7 unmodified lines

8
9
10
11
11
12
13
14
8 unmodified lines

23
24
25
26
26
27
28
29
1 unmodified line

31
32
33
34

7 unmodified lines

func TestGitHubOwnerRepo(t *testing.T) {
	stats := newStats(false)
	conn, err := newConn(Endpoint{URL: "https://github.com/torvalds/linux.git"}, "source", stats)
	conn, err := newConn(Endpoint{URL: "https://github.com/torvalds/linux.git"}, "source", stats, nil)
	if err != nil {
		t.Fatalf("new conn: %v", err)
	}
8 unmodified lines

func TestGitHubOwnerRepoRejectsNonGitHubSource(t *testing.T) {
	stats := newStats(false)
	conn, err := newConn(Endpoint{URL: "https://gitlab.com/group/project.git"}, "source", stats)
	conn, err := newConn(Endpoint{URL: "https://gitlab.com/group/project.git"}, "source", stats, nil)
	if err != nil {
		t.Fatalf("new conn: %v", err)
	}
1 unmodified line

t.Fatalf("expected non-github source to be rejected")
	}
}

Minternal/syncer/syncer_test.go+2/-3

```

2 unmodified lines

3
4
5
6
7
8
9
10
11
11
12
13
14
15
16
14
17
18
19
20
21
22
18
19
23
24
25
26
1 unmodified line

28
29
30
27
31
32
33
34
5 unmodified lines

40
41
42
39
43
44
45
46
5 unmodified lines

52
53
54
51
55
56
57
58
13 unmodified lines

72
73
74
71
75
76
77
78
79
80
81
82
83
84
85
86
87
88

2 unmodified lines

import (
	"context"
	"fmt"
	"net/http"

"github.com/soph/git-sync/internal/syncer"
)

// Options configures a Client. It is intentionally small in the first public cut.
type Options struct{}
type Options struct {
	HTTPClient *http.Client
}

// Client provides the public orchestration API for git-sync.
type Client struct{}
type Client struct {
	httpClient *http.Client
}

// New constructs a new Client.
func New(opts Options) *Client {
	_ = opts
	return &Client{}
	return &Client{httpClient: opts.HTTPClient}
}

// Probe inspects a source remote and optional target remote.
1 unmodified line

if err := req.Validate(); err != nil {
		return ProbeResult{}, err
	}
	result, err := syncer.Probe(ctx, buildProbeConfig(req))
	result, err := syncer.Probe(ctx, c.buildProbeConfig(req))
	if err != nil {
		return ProbeResult{}, err
	}
5 unmodified lines

if err := req.Validate(); err != nil {
		return PlanResult{}, err
	}
	result, err := syncer.Run(ctx, buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, true))
	result, err := syncer.Run(ctx, c.buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, true))
	if err != nil {
		return PlanResult{}, err
	}
5 unmodified lines

if err := req.Validate(); err != nil {
		return SyncResult{}, err
	}
	result, err := syncer.Run(ctx, buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, false))
	result, err := syncer.Run(ctx, c.buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, false))
	if err != nil {
		return SyncResult{}, err
	}
13 unmodified lines

return cfg
}

func buildSyncConfig(source Endpoint, sourceAuth EndpointAuth, target Endpoint, targetAuth EndpointAuth, scope RefScope, policy SyncPolicy, collectStats, dryRun bool) syncer.Config {
func (c *Client) buildProbeConfig(req ProbeRequest) syncer.Config {
	cfg := buildProbeConfig(req)
	cfg.HTTPClient = c.httpClient
	return cfg
}

func (c *Client) buildSyncConfig(source Endpoint, sourceAuth EndpointAuth, target Endpoint, targetAuth EndpointAuth, scope RefScope, policy SyncPolicy, collectStats, dryRun bool) syncer.Config {
	return syncer.Config{
		Source:                 syncer.Endpoint{URL: source.URL, Username: sourceAuth.Username, Token: sourceAuth.Token, BearerToken: sourceAuth.BearerToken, SkipTLSVerify: sourceAuth.SkipTLSVerify},
		Target:                 syncer.Endpoint{URL: target.URL, Username: targetAuth.Username, Token: targetAuth.Token, BearerToken: targetAuth.BearerToken, SkipTLSVerify: targetAuth.SkipTLSVerify},
		HTTPClient:             c.httpClient,
		Branches:               append([]string(nil), scope.Branches...),
		Mappings:               append([]RefMapping(nil), scope.Mappings...),
		IncludeTags:            policy.IncludeTags,
	}
