Merge pull request #54 from entireio/soph/ssh-transport · Entire

Home

Log in

Merge pull request #54 from entireio/soph/ssh-transport

42b9cfe→main·

Soph·2mo ago·25 files·+1,271 added/-162 removed

Add SSH transport support

Changes

25

129 unmodified lines

130
131
132
133
133
134
135
136
137
138

129 unmodified lines

### Does it support SSH?

No. `git-sync` supports smart HTTP/HTTPS only.
Yes. `git-sync` supports SSH remotes through the local `ssh` binary, including
`ssh://`, SCP-style `git@host:path.git`, and `git+ssh://` URLs. See
[docs/usage.md](docs/usage.md) for details and current caveats.

### Does it run as a daemon or watch for changes?

MREADME.md+3/-1

19 unmodified lines

20
21
22
23
23
24
25
26
27

19 unmodified lines

- The pack format itself (object types, deltas, index format) — see [Git's pack-format docs](https://git-scm.com/docs/pack-format)
- Dumb HTTP — `git-sync` does not support it
- SSH transport — `git-sync` is HTTPS-only
- Full SSH transport details — `git-sync` supports SSH, but this document is
  focused on the Smart HTTP wire flow
- Bundle URI, partial clones, and other newer extensions

## Smart HTTP Overview

Mdocs/protocol.md+2/-1

120 unmodified lines

121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

120 unmodified lines

<target-url>
```

### SSH remotes

`git-sync` also supports SSH remotes. Accepted forms include:

- `ssh://git@example.com/org/repo.git`
- `git@example.com:org/repo.git`
- `git+ssh://example.com/org/repo.git`

SSH transport shells out to the local `ssh` binary, so host aliases,
`IdentityFile`, agent-backed keys, and other `~/.ssh/config` behavior come
from your existing SSH setup rather than separate `git-sync` flags.

`git-sync` runs SSH with `BatchMode=yes`, which avoids interactive password or
host-key prompts during syncs. On first contact with a host, add it to
`known_hosts` ahead of time or configure `StrictHostKeyChecking=accept-new`
for that host in your SSH config.

Current limitation: `--progress` and `--show-stats` do not yet include
byte-counted SSH transfer metrics, so `--progress` and `--stats` omit
SSH-side throughput.

If `ssh` is not available on `PATH`, `git-sync` fails early with a clear
`locate ssh binary` error before contacting either remote.

## Sync Behavior

`sync` picks the bootstrap relay path automatically when the target is empty. For non-empty targets, safe fast-forward updates also use a relay path that streams the source pack directly into target `receive-pack` without local materialization. Anything not relay-eligible (force, prune, deletes, tag retargets) falls back to a materialized path bounded by `--materialized-max-objects`.

Mdocs/usage.md+24

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

package gitproto

import (
    "context"
    "io"
    "net/url"
)

// Conn represents a connection to a Git remote transport such as Smart HTTP
// or SSH.
type Conn interface {
    RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error)
    PostRPCStreamBody(ctx context.Context, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error)
    Endpoint() *url.URL
    ProgressWriter() io.Writer
    SetProgressWriter(w io.Writer)
    Close() error
}

Ainternal/gitproto/conn.go+18

54 unmodified lines

55
56
57
58
58
59
60
61
19 unmodified lines

81
82
83
84
84
85
86
87
14 unmodified lines

102
103
104
105
105
106
107
108
47 unmodified lines

156
157
158
159
159
160
161
162
31 unmodified lines

194
195
196
197
197
198
199
200
201
202
202
203
204
205
34 unmodified lines

240
241
242
243
243
244
245
246
184 unmodified lines

431
432
433
434
434
435
436
437
18 unmodified lines

456
457
458
459
459
460
461
462
2 unmodified lines

465
466
467
468
468
469
470
471
19 unmodified lines

491
492
493
494
494
495
496
497

54 unmodified lines

func (s *RefService) FetchToStore(
    ctx context.Context,
    store storer.Storer,
    conn *Conn,
    conn Conn,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
) error {
19 unmodified lines

// Caller must close the returned ReadCloser.
func (s *RefService) FetchPack(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
) (io.ReadCloser, error) {
14 unmodified lines

func (s *RefService) FetchCommitGraph(
    ctx context.Context,
    store storer.Storer,
    conn *Conn,
    conn Conn,
    ref DesiredRef,
    haves []plumbing.Hash,
) error {
47 unmodified lines

func fetchToStoreV2(
    ctx context.Context,
    store storer.Storer,
    conn *Conn,
    conn Conn,
    caps *V2Capabilities,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
31 unmodified lines

return err
    }
    defer ioutil.CheckClose(reader, &err)
    return storeV2FetchPack(store, reader, verbose, conn.ProgressOut)
    return storeV2FetchPack(store, reader, verbose, conn.ProgressWriter())
}

func fetchPackV2(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    caps *V2Capabilities,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
34 unmodified lines

if err != nil {
        return nil, err
    }
    packStream, err := openV2PackStream(reader, verbose, conn.ProgressOut)
    packStream, err := openV2PackStream(reader, verbose, conn.ProgressWriter())
    if err != nil {
        _ = reader.Close()
        return nil, err
184 unmodified lines

func fetchToStoreV1(
    ctx context.Context,
    store storer.Storer,
    conn *Conn,
    conn Conn,
    adv *packp.AdvRefs,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
18 unmodified lines

if drainErr := drainTrailingNAKs(buffered); drainErr != nil {
        return fmt.Errorf("drain server response: %w", drainErr)
    }
    sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut))
    sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressWriter()))
    if err := packfile.UpdateObjectStorage(store, sbReader); err != nil {
        return fmt.Errorf("update object storage: %w", err)
    }
2 unmodified lines

func fetchPackV1(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    adv *packp.AdvRefs,
    desired map[plumbing.ReferenceName]DesiredRef,
    targetRefs map[plumbing.ReferenceName]plumbing.Hash,
19 unmodified lines

return nil, fmt.Errorf("drain server response: %w", drainErr)
    }
    return &wrappedRC{
        Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut)),
        Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressWriter())),
        Closer: reader,
    }, nil
}

Minternal/gitproto/fetch.go+11/-11

361 unmodified lines

362
363
364
365
365
366
367
368
42 unmodified lines

411
412
413
414
414
415
416
417
45 unmodified lines

463
464
465
466
466
467
468
469
44 unmodified lines

514
515
516
517
517
518
519
520
31 unmodified lines

552
553
554
555
555
556
557
558
50 unmodified lines

609
610
611
612
612
613
614
615
28 unmodified lines

644
645
646
647
647
648
649
650
35 unmodified lines

686
687
688
689
689
690
691
692
40 unmodified lines

733
734
735
736
736
737
738
739
36 unmodified lines

776
777
778
779
779
780
781
782
129 unmodified lines

912
913
914
915
915
916
917
918
80 unmodified lines

999
1000
1001
1002
1002
1003
1004
1005
39 unmodified lines

1045
1046
1047
1048
1048
1049
1050
1051
36 unmodified lines

1088
1089
1090
1091
1091
1092
1093
1094

361 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
42 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
45 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
44 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("bogus\n") + "0000"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
31 unmodified lines

}

ctx, cancel := context.WithCancel(context.Background())
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        body := &blockingPacketBody{
            ctx:         req.Context(),
            startedRead: startedRead,
50 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("not-a-valid-server-response"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
28 unmodified lines

data: []byte("0008NAK\nPACK"),
        err:  io.ErrUnexpectedEOF,
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
35 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("0008NAK\nzzzz"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
40 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewReader(payload))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
36 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("0000"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
129 unmodified lines

}

seenRequest := false
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        seenRequest = true
        body, err := io.ReadAll(req.Body)
        if err != nil {
80 unmodified lines

data: wire.Bytes(),
        err:  io.ErrUnexpectedEOF,
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
39 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("packfile\n") + "zzzz"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,
36 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("packfile\n") + "zzzz"))}
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        return &http.Response{
            StatusCode: http.StatusOK,
            Request:    req,

Minternal/gitproto/fetch_test.go+14/-14

33 unmodified lines

34
35
36
37
37
38
39
40
41
42
43
44
44
45
46
47
92 unmodified lines

140
141
142
143
143
144
145
146
19 unmodified lines

166
167
168
169
169
170
171
172
173
173
174
175
176
24 unmodified lines

201
202
203
204
204
205
206
207
34 unmodified lines

242
243
244
245
245
246
247
248
27 unmodified lines

276
277
278
279
279
280
281
282

33 unmodified lines

// construction without worrying about whether downstream strategies have
// already captured a value copy.
type Pusher struct {
    Conn        *Conn
    Conn        Conn
    Adv         *packp.AdvRefs
    Verbose     bool
    OnRejection func(refName plumbing.ReferenceName, status string)
}

// NewPusher builds a target-side push executor.
func NewPusher(conn *Conn, adv *packp.AdvRefs, verbose bool) *Pusher {
func NewPusher(conn Conn, adv *packp.AdvRefs, verbose bool) *Pusher {
    return &Pusher{Conn: conn, Adv: adv, Verbose: verbose}
}

92 unmodified lines

// sendReceivePack encodes and POSTs a receive-pack request, then decodes the report.
func sendReceivePack(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    req *packp.UpdateRequests,
    packData io.Reader,
    verbose bool,
19 unmodified lines

switch {
    case req.Capabilities.Supports(capability.Sideband64k):
        dem := sideband.NewDemuxer(sideband.Sideband64k, reader)
        dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut)
        dem.Progress = progressSink(verbose, "target: ", conn.ProgressWriter())
        respReader = dem
    case req.Capabilities.Supports(capability.Sideband):
        dem := sideband.NewDemuxer(sideband.Sideband, reader)
        dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut)
        dem.Progress = progressSink(verbose, "target: ", conn.ProgressWriter())
        respReader = dem
    }

24 unmodified lines

// PushObjects pushes locally-materialized objects to the target.
func PushObjects(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    adv *packp.AdvRefs,
    commands []PushCommand,
    store storer.Storer,
34 unmodified lines

// PushPack pushes a pack stream (relay) to the target.
func PushPack(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    adv *packp.AdvRefs,
    commands []PushCommand,
    pack io.ReadCloser,
27 unmodified lines

// PushCommands sends ref update commands without a pack (for ref-only changes).
func PushCommands(
    ctx context.Context,
    conn *Conn,
    conn Conn,
    adv *packp.AdvRefs,
    commands []PushCommand,
    verbose bool,

Minternal/gitproto/push.go+8/-8

126 unmodified lines

127
128
129
130
130
131
132
133
134
135
136
136
137
138
139
49 unmodified lines

189
190
191
192
192
193
194
195
126 unmodified lines

322
323
324
325
325
326
327
328

126 unmodified lines

}))
}

func connForServer(t *testing.T, srv *httptest.Server) *Conn {
func connForServer(t *testing.T, srv *httptest.Server) *HTTPConn {
    t.Helper()
    ep, err := transport.ParseURL(srv.URL + "/repo.git")
    if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    return NewConn(ep, "test", nil, srv.Client().Transport)
    return NewHTTPConn(ep, "test", nil, srv.Client().Transport)
}

func TestPushPackClosesPackOnSuccess(t *testing.T) {
49 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "target", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "target", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
126 unmodified lines

// Use a nil-transport conn -- we should never reach the network.
    ep, err := transport.ParseURL("https://example.com/repo.git")
    require.NoError(t, err)
    conn := &Conn{Endpoint: ep, HTTP: &http.Client{}}
    conn := &HTTPConn{EndpointURL: ep, HTTP: &http.Client{}}

err = PushPack(context.Background(), conn, adv, []PushCommand{
        {Name: "refs/heads/old", Delete: true},

Minternal/gitproto/push_test.go+4/-4

15 unmodified lines

16
17
18
19
20
21
22
23
24
25
12 unmodified lines

38
39
40
37
41
42
43
44
3 unmodified lines

48
49
50
47
51
52
53
54
55
56
57
58
59
60
61
62
25 unmodified lines

88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
81
111
112
113
114
43 unmodified lines

158
159
160
131
161
162
163
164
5 unmodified lines

170
171
172
143
173
174
175
176

15 unmodified lines

"github.com/go-git/go-git/v6/plumbing/transport"
)

// GitProtocolV2 is the value passed via the Git-Protocol header / GIT_PROTOCOL
// env var to negotiate protocol v2 with the remote.
const GitProtocolV2 = "version=2"

// RefService encapsulates the result of source ref discovery and the negotiated
// protocol, providing methods for subsequent fetch and pack operations.
type RefService struct {
12 unmodified lines

// ListSourceRefs discovers refs from the source using the configured protocol mode.
// Returns the list of refs and a RefService for subsequent operations.
func ListSourceRefs(ctx context.Context, conn *Conn, protocolMode string, refPrefixes []string) ([]*plumbing.Reference, *RefService, error) {
func ListSourceRefs(ctx context.Context, conn Conn, protocolMode string, refPrefixes []string) ([]*plumbing.Reference, *RefService, error) {
    switch protocolMode {
    case "v1":
        adv, refs, err := listSourceRefsV1(ctx, conn)
3 unmodified lines

return refs, &RefService{Protocol: "v1", V1Adv: adv, HeadTarget: headTargetFromAdv(adv)}, nil

case "auto", "v2":
        data, err := RequestInfoRefs(ctx, conn, transport.UploadPackService, "version=2")
        data, err := RequestInfoRefs(ctx, conn, transport.UploadPackService, GitProtocolV2)
        if err != nil {
            if protocolMode == "auto" && isSSHScheme(conn) {
                refs, svc, v1Err := listSourceRefsAutoV1(ctx, conn)
                if v1Err != nil {
                    return nil, nil, errors.Join(err, v1Err)
                }
                return refs, svc, nil
            }
            return nil, nil, err
        }
        if caps, err := DecodeV2Capabilities(bytes.NewReader(data)); err == nil {
25 unmodified lines

}
}

func listSourceRefsAutoV1(ctx context.Context, conn Conn) ([]*plumbing.Reference, *RefService, error) {
    adv, refs, err := listSourceRefsV1(ctx, conn)
    if err != nil {
        return nil, nil, err
    }
    return refs, &RefService{Protocol: "v1", V1Adv: adv, HeadTarget: headTargetFromAdv(adv)}, nil
}

func isSSHScheme(conn Conn) bool {
    if conn == nil || conn.Endpoint() == nil {
        return false
    }
    switch conn.Endpoint().Scheme {
    case "ssh", "git+ssh":
        return true
    }
    return false
}

// AdvertisedRefsV1 fetches and decodes v1 advertised refs for the given service.
func AdvertisedRefsV1(ctx context.Context, conn *Conn, service string) (*packp.AdvRefs, error) {
func AdvertisedRefsV1(ctx context.Context, conn Conn, service string) (*packp.AdvRefs, error) {
    data, err := RequestInfoRefs(ctx, conn, service, "")
    if err != nil {
        return nil, err
43 unmodified lines

return items
}

func listSourceRefsV1(ctx context.Context, conn *Conn) (*packp.AdvRefs, []*plumbing.Reference, error) {
func listSourceRefsV1(ctx context.Context, conn Conn) (*packp.AdvRefs, []*plumbing.Reference, error) {
    adv, err := AdvertisedRefsV1(ctx, conn, transport.UploadPackService)
    if err != nil {
        return nil, nil, err
5 unmodified lines

return adv, refs, nil
}

func listSourceRefsV2(ctx context.Context, conn *Conn, caps *V2Capabilities, prefixes []string) ([]*plumbing.Reference, plumbing.ReferenceName, error) {
func listSourceRefsV2(ctx context.Context, conn Conn, caps *V2Capabilities, prefixes []string) ([]*plumbing.Reference, plumbing.ReferenceName, error) {
    // Always include "HEAD" so the server returns the symref-target attribute
    // for HEAD. Without this, callers that pass only "refs/heads/" or
    // "refs/tags/" prefixes filter HEAD out of the response and lose the

Minternal/gitproto/refs.go+35/-5

2 unmodified lines

3
4
5
6
7
8
9
10
9 unmodified lines

20
21
22
21
23
24
23
25
26
27
28
1 unmodified line

30
31
32
31
33
34
35
36
15 unmodified lines

52
53
54
53
55
56
57
58
49 unmodified lines

108
109
110
109
111
112
113
114
9 unmodified lines

124
125
126
125
126
127
128
129
130
131
70 unmodified lines

202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335

2 unmodified lines

import (
    "context"
    "errors"
    "io"
    "net/url"
    "strings"
    "testing"

9 unmodified lines

hashB := plumbing.NewHash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")

refs := []*plumbing.Reference{
        plumbing.NewHashReference("refs/heads/main", hashA),
        plumbing.NewHashReference(refsHeadsMain, hashA),
        plumbing.NewHashReference("refs/heads/dev", hashB),
        plumbing.NewSymbolicReference("HEAD", "refs/heads/main"), // symbolic, should be skipped
        plumbing.NewSymbolicReference("HEAD", refsHeadsMain), // symbolic, should be skipped
    }

m := RefHashMap(refs)
1 unmodified line

if len(m) != 2 {
        t.Fatalf("expected 2 entries, got %d", len(m))
    }
    if got := m["refs/heads/main"]; got != hashA {
    if got := m[refsHeadsMain]; got != hashA {
        t.Errorf("refs/heads/main = %s, want %s", got, hashA)
    }
    if got := m["refs/heads/dev"]; got != hashB {
15 unmodified lines

adv := &packp.AdvRefs{}
    adv.Capabilities.Add(capability.SymRef, "HEAD:refs/heads/main")
    if got := headTargetFromAdv(adv); got.String() != "refs/heads/main" {
    if got := headTargetFromAdv(adv); got.String() != refsHeadsMain {
        t.Errorf("headTargetFromAdv = %q, want refs/heads/main", got)
    }

49 unmodified lines

hashA := plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
    hashB := plumbing.NewHash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
    adv.References = []*plumbing.Reference{
        plumbing.NewHashReference("refs/heads/main", hashA),
        plumbing.NewHashReference(refsHeadsMain, hashA),
        plumbing.NewHashReference("refs/heads/dev", hashB),
    }

9 unmodified lines

for _, ref := range refs {
        found[ref.Name()] = ref.Hash()
    }
    if found["refs/heads/main"] != hashA {
        t.Errorf("refs/heads/main = %s, want %s", found["refs/heads/main"], hashA)
    if found[refsHeadsMain] != hashA {
        t.Errorf("refs/heads/main = %s, want %s", found[refsHeadsMain], hashA)
    }
    if found["refs/heads/dev"] != hashB {
        t.Errorf("refs/heads/dev = %s, want %s", found["refs/heads/dev"], hashB)
70 unmodified lines

t.Fatal("expected error for unsupported protocol mode")
    }
}

func TestListSourceRefsAutoFallsBackToV1AfterSSHV2ProbeError(t *testing.T) {
    t.Parallel()

conn := &stubConn{
        reqInfoRefs: func(_ context.Context, _ string, gitProtocol string) ([]byte, error) {
            if gitProtocol == GitProtocolV2 {
                return nil, errors.New("ssh server rejected v2 probe")
            }

var body strings.Builder
            if _, err := pktline.Writef(&body, "# service=%s\n", transport.UploadPackService); err != nil {
                t.Fatalf("write smart service line: %v", err)
            }
            if err := pktline.WriteFlush(&body); err != nil {
                t.Fatalf("write smart flush: %v", err)
            }
            if _, err := pktline.Writef(&body, "%s HEAD\x00%s\n", strings.Repeat("a", 40), capability.SymRef+"=HEAD:refs/heads/main"); err != nil {
                t.Fatalf("write advertised head: %v", err)
            }
            if _, err := pktline.Writef(&body, "%s refs/heads/main\n", strings.Repeat("a", 40)); err != nil {
                t.Fatalf("write advertised ref: %v", err)
            }
            if err := pktline.WriteFlush(&body); err != nil {
                t.Fatalf("write trailing flush: %v", err)
            }
            return []byte(body.String()), nil
        },
    }

refs, svc, err := ListSourceRefs(t.Context(), conn, "auto", nil)
    if err != nil {
        t.Fatalf("ListSourceRefs(auto) error = %v", err)
    }
    if svc.Protocol != "v1" {
        t.Fatalf("protocol = %q, want v1", svc.Protocol)
    }
    if got := svc.HeadTarget.String(); got != refsHeadsMain {
        t.Fatalf("head target = %q, want refs/heads/main", got)
    }
    foundMain := false
    for _, ref := range refs {
        if ref.Name().String() == refsHeadsMain {
            foundMain = true
            break
        }
    }
    if !foundMain {
        t.Fatalf("refs = %#v, want refs/heads/main to be advertised", refs)
    }
}

func TestListSourceRefsAutoJoinsErrorsWhenV1FallbackAlsoFails(t *testing.T) {
    t.Parallel()

v2Err := errors.New("ssh v2 probe failed")
    v1Err := errors.New("ssh v1 fallback failed")
    conn := &stubConn{
        reqInfoRefs: func(_ context.Context, _ string, gitProtocol string) ([]byte, error) {
            if gitProtocol == GitProtocolV2 {
                return nil, v2Err
            }
            return nil, v1Err
        },
    }

_, _, err := ListSourceRefs(t.Context(), conn, "auto", nil)
    if err == nil {
        t.Fatal("expected error when both v2 probe and v1 fallback fail")
    }
    if !errors.Is(err, v2Err) {
        t.Errorf("err does not wrap v2 error: %v", err)
    }
    if !errors.Is(err, v1Err) {
        t.Errorf("err does not wrap v1 error: %v", err)
    }
}

func TestListSourceRefsAutoDoesNotFallBackForNonSSH(t *testing.T) {
    t.Parallel()

v2Err := errors.New("https v2 probe failed")
    v1Called := false
    conn := &stubConn{
        reqInfoRefs: func(_ context.Context, _ string, gitProtocol string) ([]byte, error) {
            if gitProtocol == GitProtocolV2 {
                return nil, v2Err
            }
            v1Called = true
            return nil, errors.New("unexpected v1 call")
        },
        endpoint: &url.URL{Scheme: "https", Host: "github.com"},
    }

_, _, err := ListSourceRefs(t.Context(), conn, "auto", nil)
    if err == nil {
        t.Fatal("expected error from v2 probe")
    }
    if !errors.Is(err, v2Err) {
        t.Errorf("err does not wrap v2 error: %v", err)
    }
    if v1Called {
        t.Error("v1 fallback should not be attempted for non-SSH schemes")
    }
}

type stubConn struct {
    reqInfoRefs func(ctx context.Context, service string, gitProtocol string) ([]byte, error)
    endpoint    *url.URL
}

func (s *stubConn) RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error) {
    return s.reqInfoRefs(ctx, service, gitProtocol)
}

func (s *stubConn) PostRPCStreamBody(context.Context, string, io.Reader, bool, string) (io.ReadCloser, error) {
    return nil, errors.New("unexpected PostRPCStreamBody call")
}

func (s *stubConn) Endpoint() *url.URL {
    if s.endpoint != nil {
        return s.endpoint
    }
    return &url.URL{Scheme: "ssh", Host: "github.com"}
}

func (s *stubConn) ProgressWriter() io.Writer { return nil }

func (s *stubConn) SetProgressWriter(io.Writer) {}

func (s *stubConn) Close() error { return nil }

Minternal/gitproto/refs_test.go+140/-7

69 unmodified lines

70
71
72
73
74
75
76
77
78
73
74
75
76
77
78
79
80
81
14 unmodified lines

96
97
98
99
100
99
100
101
102
102
103
104
105
105
106
107
108
108
109
110
111
112
113
114
115
116
117
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
19 unmodified lines

151
152
153
146
147
154
155
156
157
158
159
160
161
162
163
164
165
166
167
4 unmodified lines

172
173
174
158
175
176
160
177
178
179
180
12 unmodified lines

193
194
195
179
196
197
181
182
183
198
199
200
201
202
203
11 unmodified lines

215
216
217
201
218
219
220
221
13 unmodified lines

235
236
237
221
238
239
240
241
242
243
227
228
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
3 unmodified lines

262
263
264
238
265
266
240
267
268
242
269
270
271
272

69 unmodified lines

Authorizer(req *http.Request) error
}

// Conn represents a connection to a remote Git HTTP endpoint.
type Conn struct {
    Label    string
    Endpoint *url.URL
    HTTP     *http.Client
    Auth     AuthMethod
// HTTPConn represents a connection to a remote Git HTTP endpoint.
type HTTPConn struct {
    Label       string
    EndpointURL *url.URL
    HTTP        *http.Client
    Auth        AuthMethod

// FollowInfoRefsRedirect, when true, rewrites Endpoint.Scheme and
    // Endpoint.Host to the final URL returned by RequestInfoRefs after
14 unmodified lines

ProgressOut io.Writer
}

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

// NewConnWithHTTPClient creates a new connection using the provided HTTP client.
// NewHTTPConnWithClient creates a new connection using the provided HTTP client.
// Passing nil falls back to a default client and is intended only for direct
// callers outside git-sync's normal instrumented session setup.
func NewConnWithHTTPClient(ep *url.URL, label string, auth AuthMethod, httpClient *http.Client) *Conn {
func NewHTTPConnWithClient(ep *url.URL, label string, auth AuthMethod, httpClient *http.Client) *HTTPConn {
    if httpClient == nil {
        httpClient = &http.Client{Transport: http.DefaultTransport}
    }
    normalizeEndpointPath(ep)
    return &Conn{
        Label:    label,
        Endpoint: ep,
        HTTP:     httpClient,
        Auth:     auth,
    return &HTTPConn{
        Label:       label,
        EndpointURL: ep,
        HTTP:        httpClient,
        Auth:        auth,
    }
}

func (c *HTTPConn) Endpoint() *url.URL { return c.EndpointURL }

func (c *HTTPConn) ProgressWriter() io.Writer { return c.ProgressOut }

func (c *HTTPConn) SetProgressWriter(w io.Writer) { c.ProgressOut = w }

func (c *HTTPConn) Close() error { return nil }

func normalizeEndpointPath(ep *url.URL) {
    if ep == nil {
        return
19 unmodified lines

}

// RequestInfoRefs fetches /info/refs for the given service.
func RequestInfoRefs(ctx context.Context, conn *Conn, service string, gitProtocol string) ([]byte, error) {
    reqURL := fmt.Sprintf("%s/info/refs?service=%s", conn.Endpoint.String(), service)
func RequestInfoRefs(ctx context.Context, conn Conn, service string, gitProtocol string) ([]byte, error) {
    data, err := conn.RequestInfoRefs(ctx, service, gitProtocol)
    if err != nil {
        return nil, fmt.Errorf("request info refs: %w", err)
    }
    return data, nil
}

// RequestInfoRefs fetches /info/refs for the given service.
func (c *HTTPConn) RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error) {
    reqURL := fmt.Sprintf("%s/info/refs?service=%s", c.EndpointURL.String(), service)
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
    if err != nil {
        return nil, fmt.Errorf("create info-refs request: %w", err)
4 unmodified lines

if gitProtocol != "" {
        req.Header.Set("Git-Protocol", gitProtocol)
    }
    ApplyAuth(req, conn.Auth)
    ApplyAuth(req, c.Auth)

res, err := conn.HTTP.Do(req)
    res, err := c.HTTP.Do(req)
    if err != nil {
        return nil, fmt.Errorf("request info-refs: %w", err)
    }
12 unmodified lines

if gotMediaType != wantContentType {
        return nil, fmt.Errorf("unexpected info/refs content-type %q, want %q", gotContentType, wantContentType)
    }
    if conn.FollowInfoRefsRedirect && res.Request != nil && res.Request.URL != nil {
    if c.FollowInfoRefsRedirect && res.Request != nil && res.Request.URL != nil {
        final := res.Request.URL
        if final.Host != conn.Endpoint.Host || final.Scheme != conn.Endpoint.Scheme {
            conn.Endpoint.Scheme = final.Scheme
            conn.Endpoint.Host = final.Host
        if final.Host != c.EndpointURL.Host || final.Scheme != c.EndpointURL.Scheme {
            c.EndpointURL.Scheme = final.Scheme
            c.EndpointURL.Host = final.Host
        }
    }
    // Bound the read to prevent unbounded memory allocation (issue #9).
11 unmodified lines

// PostRPC sends a buffered POST to the given service and returns the full response body.
// Responses are bounded to prevent unbounded memory allocation (issue #9).
func PostRPC(ctx context.Context, conn *Conn, service string, body []byte, v2 bool, phase string) ([]byte, error) {
func PostRPC(ctx context.Context, conn Conn, service string, body []byte, v2 bool, phase string) ([]byte, error) {
    reader, err := PostRPCStream(ctx, conn, service, body, v2, phase)
    if err != nil {
        return nil, err
13 unmodified lines

// PostRPCStream sends a POST to the given service and returns the response body
// as a streaming reader. Caller must close the returned ReadCloser.
func PostRPCStream(ctx context.Context, conn *Conn, service string, body []byte, v2 bool, phase string) (io.ReadCloser, error) {
func PostRPCStream(ctx context.Context, conn Conn, service string, body []byte, v2 bool, phase string) (io.ReadCloser, error) {
    return PostRPCStreamBody(ctx, conn, service, bytes.NewReader(body), v2, phase)
}

// PostRPCStreamBody sends a POST to the given service using a streaming request body.
// Caller must close the returned ReadCloser.
func PostRPCStreamBody(ctx context.Context, conn *Conn, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error) {
    reqURL := fmt.Sprintf("%s/%s", conn.Endpoint.String(), service)
func PostRPCStreamBody(ctx context.Context, conn Conn, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error) {
    reader, err := conn.PostRPCStreamBody(ctx, service, body, v2, phase)
    if err != nil {
        return nil, fmt.Errorf("post RPC stream body: %w", err)
    }
    return reader, nil
}

// PostRPCStreamBody sends a POST to the given service using a streaming request body.
// Caller must close the returned ReadCloser.
func (c *HTTPConn) PostRPCStreamBody(ctx context.Context, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error) {
    reqURL := fmt.Sprintf("%s/%s", c.EndpointURL.String(), service)
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, body)
    if err != nil {
        return nil, fmt.Errorf("create RPC request: %w", err)
3 unmodified lines

req.Header.Set("User-Agent", capability.DefaultAgent())
    req.Header.Set(StatsPhaseHeader, phase)
    if v2 {
        req.Header.Set("Git-Protocol", "version=2")
        req.Header.Set("Git-Protocol", GitProtocolV2)
    }
    ApplyAuth(req, conn.Auth)
    ApplyAuth(req, c.Auth)

res, err := conn.HTTP.Do(req)
    res, err := c.HTTP.Do(req)
    if err != nil {
        return nil, fmt.Errorf("post RPC: %w", err)
    }

Minternal/gitproto/smarthttp.go+58/-31

14 unmodified lines

15
16
17
18
18
19
20
21
22
23
24
24
25
26
27
28
29
30
29
30
31
32
33
3 unmodified lines

37
38
39
40
40
41
42
43
44
45
46
46
47
48
49
8 unmodified lines

58
59
60
61
62
61
62
63
64
65
75 unmodified lines

141
142
143
144
144
145
146
147
3 unmodified lines

151
152
153
154
154
155
156
157
53 unmodified lines

211
212
213
214
214
215
216
217
32 unmodified lines

250
251
252
253
253
254
255
256
28 unmodified lines

285
286
287
288
288
289
290
291
15 unmodified lines

307
308
309
310
310
311
312
313
1 unmodified line

315
316
317
318
319
318
319
320
321
322
37 unmodified lines

360
361
362
363
363
364
365
366
20 unmodified lines

387
388
389
390
390
391
392
393
13 unmodified lines

407
408
409
410
410
411
412
413
414
415
416
417
418
417
418
419
420
421

14 unmodified lines

transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"
)

func TestNewConn(t *testing.T) {
func TestNewHTTPConn(t *testing.T) {
    ep, err := transport.ParseURL("https://github.com/user/repo.git")
    if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    auth := &transporthttp.BasicAuth{Username: "user", Password: "pass"}
    conn := NewConn(ep, "test-label", auth, http.DefaultTransport)
    conn := NewHTTPConn(ep, "test-label", auth, http.DefaultTransport)

if conn.Label != "test-label" {
        t.Errorf("Label = %q, want %q", conn.Label, "test-label")
    }
    if conn.Endpoint != ep {
        t.Error("Endpoint mismatch")
    if conn.EndpointURL != ep {
        t.Error("EndpointURL mismatch")
    }
    if conn.Auth != auth {
        t.Error("Auth mismatch")
3 unmodified lines

}
}

func TestNewConnStripsTrailingEndpointSlash(t *testing.T) {
func TestNewHTTPConnStripsTrailingEndpointSlash(t *testing.T) {
    ep, err := url.Parse("https://example.com/repo.git///")
    if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    var gotURLs []string
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        gotURLs = append(gotURLs, req.URL.String())
        res := &http.Response{
            StatusCode: http.StatusOK,
8 unmodified lines

return res, nil
    }))

if got, want := conn.Endpoint.Path, "/repo.git"; got != want {
        t.Fatalf("Endpoint.Path = %q, want %q", got, want)
    if got, want := conn.EndpointURL.Path, "/repo.git"; got != want {
        t.Fatalf("EndpointURL.Path = %q, want %q", got, want)
    }
    if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
        t.Fatalf("RequestInfoRefs: %v", err)
75 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
3 unmodified lines

done := make(chan error, 1)
    go func() {
        _, err := RequestInfoRefs(ctx, conn, "git-upload-pack", "version=2")
        _, err := RequestInfoRefs(ctx, conn, "git-upload-pack", GitProtocolV2)
        done <- err
    }()

53 unmodified lines

if err != nil {
                t.Fatalf("parse endpoint: %v", err)
            }
            conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
            conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
                res := &http.Response{
                    StatusCode: http.StatusOK,
                    Request:    req,
32 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
    conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
        started <- struct{}{}
        <-req.Context().Done()
        return nil, req.Context().Err()
28 unmodified lines

}

// TestRequestInfoRefs_FollowInfoRefsRedirect verifies that when the flag is
// set, a 307 on /info/refs rewrites Conn.Endpoint.Host so subsequent PostRPC
// set, a 307 on /info/refs rewrites HTTPConn.EndpointURL.Host so subsequent PostRPC
// calls target the redirected node. Matches vanilla git's smart-HTTP
// behaviour and lets clients use a cluster entry domain for info/refs while
// packs land on the hosting replica.
15 unmodified lines

if err != nil {
        t.Fatalf("parse endpoint: %v", err)
    }
    conn := NewConn(ep, "test", nil, http.DefaultTransport)
    conn := NewHTTPConn(ep, "test", nil, http.DefaultTransport)
    conn.FollowInfoRefsRedirect = true

if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
1 unmodified line

}

nodeURL := strings.TrimPrefix(node.URL, "http://")
    if conn.Endpoint.Host != nodeURL {
        t.Errorf("Endpoint.Host = %q, want %q (endpoint should follow the 307)", conn.Endpoint.Host, nodeURL)
    if conn.EndpointURL.Host != nodeURL {
        t.Errorf("EndpointURL.Host = %q, want %q (endpoint should follow the 307)", conn.EndpointURL.Host, nodeURL)
    }
}

37 unmodified lines

if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
20 unmodified lines

}

// TestRequestInfoRefs_DoesNotFollowByDefault confirms the default behaviour
// is unchanged: Endpoint is stable even if the server 307s.
// is unchanged: EndpointURL is stable even if the server 307s.
func TestRequestInfoRefs_DoesNotFollowByDefault(t *testing.T) {
    node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
13 unmodified lines

t.Fatalf("parse endpoint: %v", err)
    }
    entryHost := ep.Host
    conn := NewConn(ep, "test", nil, http.DefaultTransport)
    conn := NewHTTPConn(ep, "test", nil, http.DefaultTransport)
    // FollowInfoRefsRedirect intentionally not set.

if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
        t.Fatalf("RequestInfoRefs: %v", err)
    }

if conn.Endpoint.Host != entryHost {
        t.Errorf("Endpoint.Host = %q, want %q (endpoint should be unchanged by default)", conn.Endpoint.Host, entryHost)
    if conn.EndpointURL.Host != entryHost {
        t.Errorf("EndpointURL.Host = %q, want %q (endpoint should be unchanged by default)", conn.EndpointURL.Host, entryHost)
    }
}

Minternal/gitproto/smarthttp_test.go+21/-21

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334

package gitproto

import (
    "bufio"
    "bytes"
    "context"
    "errors"
    "fmt"
    "io"
    "net/url"
    "os/exec"
    "strings"
    "sync"
)

// SSHLookPath is replaceable in tests.
var SSHLookPath = exec.LookPath

// SSHConn represents a Git transport over the local ssh binary.
type SSHConn struct {
    Label       string
    EndpointURL *url.URL
    sshPath     string
    progressOut io.Writer
}

// NewSSHConn creates a new SSH transport connection backed by the local ssh
// binary.
func NewSSHConn(ep *url.URL, label string) (*SSHConn, error) {
    sshPath, err := SSHLookPath("ssh")
    if err != nil {
        return nil, fmt.Errorf("locate ssh binary: %w", err)
    }
    normalizeEndpointPath(ep)
    return &SSHConn{
        Label:       label,
        EndpointURL: ep,
        sshPath:     sshPath,
    }, nil
}

func (c *SSHConn) Endpoint() *url.URL { return c.EndpointURL }

func (c *SSHConn) ProgressWriter() io.Writer { return c.progressOut }

func (c *SSHConn) SetProgressWriter(w io.Writer) { c.progressOut = w }

func (c *SSHConn) Close() error { return nil }

func (c *SSHConn) RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error) {
    cmd, stderr, err := c.startRPC(ctx, service, gitProtocol)
    if err != nil {
        return nil, err
    }
    return requestInfoRefsWithCommand(ctx, service, cmd, stderr)
}

func requestInfoRefsWithCommand(ctx context.Context, service string, cmd *sshCommand, stderr *sshCommandError) ([]byte, error) {
    if err := cmd.Stdin.Close(); err != nil {
        return nil, fmt.Errorf("close ssh stdin for %s: %w", service, errors.Join(err, cleanupSSHCommand(cmd)))
    }
    data, readErr := io.ReadAll(cmd.Stdout)
    waitErr := cmd.wait()
    if ctx.Err() != nil {
        return nil, errors.Join(ctx.Err(), readErr, stderr.wrap(waitErr))
    }
    if readErr != nil {
        return nil, fmt.Errorf("%s info-refs: %w", service, readErr)
    }
    if len(data) > 0 {
        return data, nil
    }
    if waitErr != nil {
        return nil, fmt.Errorf("%s info-refs: %w", service, stderr.wrap(waitErr))
    }
    return data, nil
}

func (c *SSHConn) PostRPCStreamBody(ctx context.Context, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error) {
    _ = phase // phase labels are HTTP-only today; SSH transport has no per-RPC stats tagging
    gitProtocol := ""
    if v2 {
        gitProtocol = GitProtocolV2
    }
    cmd, stderr, err := c.startRPC(ctx, service, gitProtocol)
    if err != nil {
        return nil, err
    }
    copyErr := make(chan error, 1)
    go func() {
        _, err := io.Copy(cmd.Stdin, body)
        closeErr := cmd.Stdin.Close()
        if err != nil {
            copyErr <- err
            return
        }
        copyErr <- closeErr
    }()
    stdout, err := discardSSHAdvertisement(cmd.Stdout)
    if err != nil {
        _ = cmd.Stdout.Close()
        waitErr := cmd.wait()
        return nil, fmt.Errorf("%s advertisement: %w", service, errors.Join(stderr.wrap(err), waitErr))
    }
    return &sshRPCStream{
        ctx:     ctx,
        stdout:  stdout,
        wait:    cmd.wait,
        copyErr: copyErr,
        stderr:  stderr,
    }, nil
}

func (c *SSHConn) startRPC(ctx context.Context, service string, gitProtocol string) (*sshCommand, *sshCommandError, error) {
    args, err := sshInvocationArgs(c.EndpointURL, service, gitProtocol)
    if err != nil {
        return nil, nil, err
    }
    cmd := exec.CommandContext(ctx, c.sshPath, args...)
    stderr := &sshCommandError{}
    cmd.Stderr = stderr
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return nil, nil, fmt.Errorf("open ssh stdout for %s: %w", service, err)
    }
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, nil, fmt.Errorf("open ssh stdin for %s: %w", service, err)
    }
    if err := cmd.Start(); err != nil {
        return nil, nil, fmt.Errorf("start ssh for %s: %w", service, stderr.wrap(err))
    }
    return &sshCommand{Cmd: cmd, Stdin: stdin, Stdout: stdout}, stderr, nil
}

func sshInvocationArgs(ep *url.URL, service string, gitProtocol string) ([]string, error) {
    destination, err := sshDestination(ep)
    if err != nil {
        return nil, err
    }
    remoteCommand, err := sshRemoteCommand(ep, service, gitProtocol)
    if err != nil {
        return nil, err
    }
    args := []string{"-o", "BatchMode=yes"}
    if port := ep.Port(); port != "" {
        args = append(args, "-p", port)
    }
    args = append(args, destination, remoteCommand)
    return args, nil
}

func sshDestination(ep *url.URL) (string, error) {
    if ep == nil || ep.Hostname() == "" {
        return "", errors.New("missing SSH host")
    }
    host := ep.Hostname()
    if ep.User != nil && ep.User.Username() != "" {
        return ep.User.Username() + "@" + host, nil
    }
    return host, nil
}

func sshRemoteCommand(ep *url.URL, service string, gitProtocol string) (string, error) {
    if ep == nil || ep.Path == "" {
        return "", errors.New("missing SSH repository path")
    }
    path := shellQuotePath(ep.Path)
    if gitProtocol != "" {
        return gitProtocolEnv(gitProtocol) + " " + service + " " + path, nil
    }
    return service + " " + path, nil
}

func gitProtocolEnv(gitProtocol string) string {
    return "GIT_PROTOCOL=" + shellQuote(gitProtocol)
}

func shellQuote(s string) string {
    return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
}

func shellQuotePath(path string) string {
    if !strings.HasPrefix(path, "~") {
        return shellQuote(path)
    }
    slash := strings.IndexByte(path, '/')
    if slash < 0 {
        return path
    }
    return path[:slash+1] + shellQuote(path[slash+1:])
}

type sshCommand struct {
    Cmd    *exec.Cmd
    Stdin  io.WriteCloser
    Stdout io.ReadCloser
    waitFn func() error
}

func (c *sshCommand) wait() error {
    if c.waitFn != nil {
        return c.waitFn()
    }
    if err := c.Cmd.Wait(); err != nil {
        return fmt.Errorf("wait for ssh command: %w", err)
    }
    return nil
}

func cleanupSSHCommand(cmd *sshCommand) error {
    if cmd == nil {
        return nil
    }
    var errs []error
    if cmd.Stdout != nil {
        if err := cmd.Stdout.Close(); err != nil {
            errs = append(errs, fmt.Errorf("close ssh stdout: %w", err))
        }
    }
    if err := cmd.wait(); err != nil {
        errs = append(errs, err)
    }
    return errors.Join(errs...)
}

func discardSSHAdvertisement(stdout io.ReadCloser) (io.ReadCloser, error) {
    buffered := bufio.NewReader(stdout)
    header, err := buffered.Peek(4)
    if err != nil {
        if errors.Is(err, io.EOF) {
            return &bufferedReadCloser{Reader: buffered, Closer: stdout}, nil
        }
        return nil, fmt.Errorf("peek SSH advertisement: %w", err)
    }
    if !looksLikePktlineHeader(header) {
        return &bufferedReadCloser{Reader: buffered, Closer: stdout}, nil
    }
    reader := NewPacketReader(buffered)
    for {
        kind, _, err := reader.ReadPacket()
        if err != nil {
            return nil, err
        }
        if kind == PacketFlush {
            break
        }
    }
    return &bufferedReadCloser{Reader: reader.BufReader(), Closer: stdout}, nil
}

func looksLikePktlineHeader(header []byte) bool {
    if len(header) != 4 {
        return false
    }
    var fixed [4]byte
    copy(fixed[:], header)
    switch string(fixed[:]) {
    case "0000", "0001", "0002":
        return true
    }
    _, err := parseHexLength(fixed)
    return err == nil
}

type sshRPCStream struct {
    ctx     context.Context
    stdout  io.ReadCloser
    wait    func() error
    copyErr <-chan error
    stderr  *sshCommandError

waitOnce sync.Once
    waitErr  error
}

type bufferedReadCloser struct {
    *bufio.Reader
    io.Closer
}

func (s *sshRPCStream) Read(p []byte) (int, error) {
    n, err := s.stdout.Read(p)
    return n, err //nolint:wrapcheck // io.Reader contract requires forwarding EOF and stream errors as-is
}

func (s *sshRPCStream) Close() error {
    closeErr := s.stdout.Close()
    s.waitOnce.Do(func() {
        copyErr := <-s.copyErr
        waitErr := s.wait()
        if copyErr != nil {
            s.waitErr = copyErr
            if waitErr != nil {
                s.waitErr = errors.Join(copyErr, s.stderr.wrap(waitErr))
            }
            return
        }
        if waitErr != nil {
            s.waitErr = s.stderr.wrap(waitErr)
        }
        if s.ctx != nil && s.ctx.Err() != nil {
            s.waitErr = errors.Join(s.ctx.Err(), s.waitErr)
        }
    })
    return errors.Join(closeErr, s.waitErr)
}

type sshCommandError struct {
    mu  sync.Mutex
    buf bytes.Buffer
}

func (e *sshCommandError) Write(p []byte) (int, error) {
    e.mu.Lock()
    defer e.mu.Unlock()
    return e.buf.Write(p) //nolint:wrapcheck // io.Writer implementation forwards buffer write errors verbatim
}

func (e *sshCommandError) String() string {
    e.mu.Lock()
    defer e.mu.Unlock()
    return strings.TrimSpace(e.buf.String())
}

func (e *sshCommandError) wrap(err error) error {
    if err == nil {
        return nil
    }
    if msg := e.String(); msg != "" {
        return fmt.Errorf("%w: %s", err, msg)
    }
    return err
}

Ainternal/gitproto/ssh.go+334

package gitproto

import ( "context" "errors" "io" "os" "path/filepath" "strconv" "strings" "testing" "time"

"github.com/go-git/go-git/v6/plumbing/transport" )

func TestNewSSHConnRequiresBinary(t *testing.T) { orig := SSHLookPath t.Cleanup(func() { SSHLookPath = orig }) SSHLookPath = func(string) (string, error) { return "", errors.New("not found") }

ep, err := transport.ParseURL("ssh://example.com/repo.git") if err != nil { t.Fatalf("parse url: %v", err) } _, err = NewSSHConn(ep, "source") if err == nil || !strings.Contains(err.Error(), "locate ssh binary") { t.Fatalf("NewSSHConn error = %v, want locate ssh binary failure", err) } }

func TestSSHConnRequestInfoRefsHonorsUserConfigAndProtocolV2(t *testing.T) { env := newSSHShimEnv(t) conn := newSSHTestConn(t, "ssh://example.com/repo.git", env.script)

body, err := conn.RequestInfoRefs(t.Context(), "git-upload-pack", GitProtocolV2) if err != nil { t.Fatalf("RequestInfoRefs: %v", err) } if string(body) != "response-1" { t.Fatalf("RequestInfoRefs body = %q, want %q", body, "response-1") }

logLine := env.logLines(t)[0] if got, want := logLine, "example.com\tGIT_PROTOCOL='version=2' git-upload-pack '/repo.git'"; got != want { t.Fatalf("ssh invocation = %q, want %q", got, want) } }

func TestSSHConnRequestInfoRefsSupportsSCPStyleAndPort(t *testing.T) { env := newSSHShimEnv(t)

scpConn := newSSHTestConn(t, "git@example.com:repo.git", env.script) if _, err := scpConn.RequestInfoRefs(t.Context(), "git-upload-pack", ""); err != nil { t.Fatalf("scp RequestInfoRefs: %v", err) }

portConn := newSSHTestConn(t, "ssh://alice@example.com:2222/repo.git", env.script) if _, err := portConn.RequestInfoRefs(t.Context(), "git-upload-pack", ""); err != nil { t.Fatalf("port RequestInfoRefs: %v", err) }

lines := env.logLines(t) if got, want := lines[0], "git@example.com\tgit-upload-pack 'repo.git'"; got != want { t.Fatalf("scp invocation = %q, want %q", got, want) } if got, want := lines[1], "-p 2222 alice@example.com\tgit-upload-pack '/repo.git'"; got != want { t.Fatalf("port invocation = %q, want %q", got, want) } }

func TestSSHConnRequestInfoRefsPreservesTildePaths(t *testing.T) { env := newSSHShimEnv(t)

conn := newSSHTestConn(t, "git@example.com:~/repo with spaces.git", env.script) if _, err := conn.RequestInfoRefs(t.Context(), "git-upload-pack", ""); err != nil { t.Fatalf("RequestInfoRefs: %v", err) } if got, want := env.logLines(t)[0], "git@example.com\tgit-upload-pack ~/'repo with spaces.git'"; got != want { t.Fatalf("ssh invocation = %q, want %q", got, want) } }

func TestSSHConnPostRPCStreamBodyCanBeCalledRepeatedly(t *testing.T) { env := newSSHShimEnv(t) conn := newSSHTestConn(t, "ssh://example.com/repo.git", env.script)

reader1, err := conn.PostRPCStreamBody(t.Context(), "git-upload-pack", strings.NewReader("first-body"), false, "fetch one") if err != nil { t.Fatalf("PostRPCStreamBody first: %v", err) } data1, err := io.ReadAll(reader1) if err != nil { t.Fatalf("read first response: %v", err) } if err := reader1.Close(); err != nil { t.Fatalf("close first response: %v", err) }

reader2, err := conn.PostRPCStreamBody(t.Context(), "git-upload-pack", strings.NewReader("second-body"), false, "fetch two") if err != nil { t.Fatalf("PostRPCStreamBody second: %v", err) } data2, err := io.ReadAll(reader2) if err != nil { t.Fatalf("read second response: %v", err) } if err := reader2.Close(); err != nil { t.Fatalf("close second response: %v", err) }

if string(data1) != "response-1" || string(data2) != "response-2" { t.Fatalf("responses = %q / %q, want response-1 / response-2", data1, data2) } if got, want := env.body(t, 1), "first-body"; got != want { t.Fatalf("first request body = %q, want %q", got, want) } if got, want := env.body(t, 2), "second-body"; got != want { t.Fatalf("second request body = %q, want %q", got, want) } if got := len(env.logLines(t)); got != 2 { t.Fatalf("ssh invocation count = %d, want 2", got) } }

func TestSSHConnRequestInfoRefsHonorsContext(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "ssh-sleep.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 5\n"), 0o755); err != nil { t.Fatalf("write script: %v", err) }

conn := newSSHTestConn(t, "ssh://example.com/repo.git", script) ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) defer cancel()

_, err := conn.RequestInfoRefs(ctx, "git-upload-pack", "") if err == nil { t.Fatal("RequestInfoRefs returned nil error on canceled context") } if !strings.Contains(err.Error(), "context deadline exceeded") { t.Fatalf("RequestInfoRefs error = %v, want context deadline exceeded", err) } }

func TestSSHConnPostRPCStreamBodyHonorsContext(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "ssh-read-sleep.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\ncat >/dev/null\nsleep 5\n"), 0o755); err != nil { t.Fatalf("write script: %v", err) }

conn := newSSHTestConn(t, "ssh://example.com/repo.git", script) ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) defer cancel()

reader, err := conn.PostRPCStreamBody(ctx, "git-upload-pack", strings.NewReader("body"), false, "fetch") if err != nil { t.Fatalf("PostRPCStreamBody: %v", err) } if _, err := io.ReadAll(reader); err != nil && !strings.Contains(err.Error(), "context deadline exceeded") { t.Fatalf("ReadAll error = %v", err) } if err := reader.Close(); err == nil || !strings.Contains(err.Error(), "context deadline exceeded") { t.Fatalf("reader.Close error = %v, want context deadline exceeded", err) } }

func TestRequestInfoRefsCleansUpWhenStdinCloseFails(t *testing.T) { t.Parallel()

stdoutClosed := false waitCalled := false _, err := requestInfoRefsWithCommand( t.Context(), "git-upload-pack", &sshCommand{ Stdin: closeWriterFunc(func() error { return errors.New("close failed") }), Stdout: closeReaderFunc(func() error { stdoutClosed = true; return nil }), waitFn: func() error { waitCalled = true; return nil }, }, &sshCommandError{}, ) if err == nil || !strings.Contains(err.Error(), "close ssh stdin for git-upload-pack") { t.Fatalf("requestInfoRefsWithCommand error = %v", err) } if !stdoutClosed { t.Fatal("stdout was not closed on stdin-close failure") } if !waitCalled { t.Fatal("wait was not called on stdin-close failure") } }

type sshShimEnv struct { script string logFile string bodyPrefix string }

type closeWriterFunc func() error

func (f closeWriterFunc) Write(p []byte) (int, error) { return len(p), nil } func (f closeWriterFunc) Close() error { return f() }

type closeReaderFunc func() error

func (f closeReaderFunc) Read([]byte) (int, error) { return 0, io.EOF } func (f closeReaderFunc) Close() error { return f() }

func newSSHShimEnv(t *testing.T) sshShimEnv { t.Helper() dir := t.TempDir() logFile := filepath.Join(dir, "ssh.log") countFile := filepath.Join(dir, "count") bodyPrefix := filepath.Join(dir, "body-") script := filepath.Join(dir, "ssh-shim.sh") content := strings.Join([]string{ "#!/bin/sh", "count=0", "if [ -f " + shellQuote(countFile) + " ]; then count=$(cat " + shellQuote(countFile) + "); fi", "count=$((count+1))", "printf '%s' "$count" >" + shellQuote(countFile), "dest=""", "remote=""", "if [ "$1" = "-o" ]; then", " shift 2", "fi", "if [ "$1" = "-p" ]; then", " port="$2"", " shift 2", " dest="-p $port $1"", "else", " dest="$1"", "fi", "remote="$2"", "printf '%s\t%s\n' "$dest" "$remote" >>" + shellQuote(logFile), "cat >" + shellQuote(bodyPrefix) + ""$count"", "printf 'response-%s' "$count"", }, "\n") if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatalf("write ssh shim: %v", err) } return sshShimEnv{script: script, logFile: logFile, bodyPrefix: bodyPrefix} }

func newSSHTestConn(t *testing.T, rawURL, script string) *SSHConn { t.Helper() orig := SSHLookPath t.Cleanup(func() { SSHLookPath = orig }) SSHLookPath = func(string) (string, error) { return script, nil }

ep, err := transport.ParseURL(rawURL) if err != nil { t.Fatalf("parse url %q: %v", rawURL, err) } conn, err := NewSSHConn(ep, "source") if err != nil { t.Fatalf("NewSSHConn(%q): %v", rawURL, err) } return conn }

func (e sshShimEnv) logLines(t *testing.T) []string { t.Helper() data, err := os.ReadFile(e.logFile) if err != nil { t.Fatalf("read log: %v", err) } trimmed := strings.TrimSpace(string(data)) if trimmed == "" { return nil } return strings.Split(trimmed, "\n") }

func (e sshShimEnv) body(t *testing.T, count int) string { t.Helper() data, err := os.ReadFile(e.bodyPrefix + strconv.Itoa(count)) if err != nil { t.Fatalf("read body %d: %v", count, err) } return string(data) }


Ainternal/gitproto/ssh\_test.go+286

39 unmodified lines

40 41 42 43 43 44 45 46 45 46 47 48 49 1276 unmodified lines

1326 1327 1328 1329 1329 1330 1331 1332 13 unmodified lines

1346 1347 1348 1349 1349 1350 1351 1352 1353 1354 1355 1356 7 unmodified lines

1364 1365 1366 1363 1367 1368 1369 1370 11 unmodified lines

1382 1383 1384 1381 1382 1385 1386 1387 1388 1385 1389 1390 1391 1392

39 unmodified lines

// Params holds the inputs for a bootstrap execution. type Params struct { SourceConn *gitproto.Conn SourceConn gitproto.Conn SourceService interface { FetchPack(ctx context.Context, conn *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) FetchCommitGraph(ctx context.Context, store storer.Storer, conn *gitproto.Conn, ref gitproto.DesiredRef, haves []plumbing.Hash) error FetchPack(ctx context.Context, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) FetchCommitGraph(ctx context.Context, store storer.Storer, conn gitproto.Conn, ref gitproto.DesiredRef, haves []plumbing.Hash) error SupportsBootstrapBatch() bool } TargetPusher interface { 1276 unmodified lines

// --- GitHub preflight ---

func githubBatchLimit(ctx context.Context, p Params) (int64, bool) { if p.TargetMaxPack > 0 || p.SourceConn == nil || p.SourceConn.Endpoint == nil { if p.TargetMaxPack > 0 || p.SourceConn == nil || p.SourceConn.Endpoint() == nil { return 0, false } if p.SourceService == nil || !p.SourceService.SupportsBootstrapBatch() { 13 unmodified lines

return limit, true }

func lookupGitHubRepoSizeKB(ctx context.Context, conn *gitproto.Conn) (int64, bool) { func lookupGitHubRepoSizeKB(ctx context.Context, conn gitproto.Conn) (int64, bool) { httpConn, ok := conn.(*gitproto.HTTPConn) if !ok { return 0, false } owner, repo, ok := GitHubOwnerRepo(conn) if !ok { return 0, false 7 unmodified lines

req.Header.Set("X-Github-Api-Version", "2022-11-28") req.Header.Set("User-Agent", capability.DefaultAgent()) req.Header.Set(gitproto.StatsPhaseHeader, "github repo metadata") resp, err := conn.HTTP.Do(req) resp, err := httpConn.HTTP.Do(req) if err != nil { return 0, false } 11 unmodified lines

}

// GitHubOwnerRepo extracts the owner/repo from a GitHub endpoint. func GitHubOwnerRepo(conn *gitproto.Conn) (string, string, bool) { if conn == nil || conn.Endpoint == nil { func GitHubOwnerRepo(conn gitproto.Conn) (string, string, bool) { if conn == nil || conn.Endpoint() == nil { return "", "", false } ep := conn.Endpoint ep := conn.Endpoint() if ep.Scheme != "http" && ep.Scheme != "https" { return "", "", false }


Minternal/strategy/bootstrap/bootstrap.go+13/-9

1148 unmodified lines

1149 1150 1151 1152 1152 1153 1154 1155 1 unmodified line

1157 1158 1159 1160 1160 1161 1162 1163 48 unmodified lines

1212 1213 1214 1215 1216 1215 1216 1217 1218 1219 1220 1221 1221 1222 1223 1224 3 unmodified lines

1228 1229 1230 1231 1231 1232 1233 1234 63 unmodified lines

1298 1299 1300 1301 1301 1302 1303 1304 39 unmodified lines

1344 1345 1346 1347 1347 1348 1349 1350 27 unmodified lines

1378 1379 1380 1381 1381 1382 1383 1384 26 unmodified lines

1411 1412 1413 1414 1414 1415 1416 1417 1418 1418 1419 1420 1421 29 unmodified lines

1451 1452 1453 1454 1454 1455 1456 1457 1458 1458 1459 1460 1461 40 unmodified lines

1502 1503 1504 1505 1505 1506 1507 1508 37 unmodified lines

1546 1547 1548 1549 1550 1551 1549 1550 1551 1552 1553 1554 1554 1555 1556 1557

1148 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchCommitGraph: func( context.Context, store storer.Storer, _ *gitproto.Conn, ref gitproto.DesiredRef, _ []plumbing.Hash) error { fetchCommitGraph: func(_ context.Context, store storer.Storer, _ gitproto.Conn, ref gitproto.DesiredRef, _ []plumbing.Hash) error { graphFetches++ if ref.SourceRef != mainRef { t.Errorf("unexpected commit-graph fetch for %s; subsumed branch should have been skipped", ref.SourceRef) 1 unmodified line

writeLinearCommitChain(t, store, 3) return nil }, fetchPack: func(_ context.Context, _ *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { packFetches++ if _, ok := desired[featureRef]; ok { t.Errorf("unexpected pack fetch including feature ref: %+v", desired) 48 unmodified lines

}

type fakeBootstrapSource struct { fetchPack func(context.Context, *gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) fetchCommitGraph func(context.Context, storer.Storer, *gitproto.Conn, gitproto.DesiredRef, []plumbing.Hash) error fetchPack func(context.Context, gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) fetchCommitGraph func(context.Context, storer.Storer, gitproto.Conn, gitproto.DesiredRef, []plumbing.Hash) error }

func (f fakeBootstrapSource) FetchPack( ctx context.Context, conn *gitproto.Conn, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash, ) (io.ReadCloser, error) { 3 unmodified lines

func (f fakeBootstrapSource) FetchCommitGraph( ctx context.Context, store storer.Storer, conn *gitproto.Conn, conn gitproto.Conn, ref gitproto.DesiredRef, haves []plumbing.Hash, ) error { 63 unmodified lines

result, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchPack: func(_ context.Context, _ *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { gotDesired = desired if targetRefs != nil { t.Fatalf("expected nil target refs during one-shot bootstrap fetch, got %v", targetRefs) 39 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchPack: func( context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { return pack, nil }, }, 27 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchPack: func( context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { return pack, nil }, }, 26 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchCommitGraph: func( context.Context, store storer.Storer, _ *gitproto.Conn, _ gitproto.DesiredRef, _ []plumbing.Hash) error { fetchCommitGraph: func(_ context.Context, store storer.Storer, _ gitproto.Conn, _ gitproto.DesiredRef, _ []plumbing.Hash) error { writeLinearCommitChain(t, store, 1) return nil }, fetchPack: func(_ context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { return pack, nil }, }, 29 unmodified lines

calledFetch := false _, err := Execute(context.Background(), Params{ SourceService: fakeBootstrapSource{ fetchPack: func(context.Context, *gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(context.Context, gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { calledFetch = true return io.NopCloser(bytes.NewReader(nil)), nil }, 37 unmodified lines

}

_, err = Execute(context.Background(), Params{ SourceConn: &gitproto.Conn{ Endpoint: ep, HTTP: server.Client(), SourceConn: &gitproto.HTTPConn{ EndpointURL: ep, HTTP: server.Client(), }, SourceService: fakeBootstrapSource{ fetchPack: func(context.Context, *gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(context.Context, gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { t.Fatal("unexpected fetch") return nil, nil //nolint:nilnil // test fake returns nil to signal no data },


Minternal/strategy/bootstrap/bootstrap\_test.go+18/-18

18 unmodified lines

19 20 21 22 22 23 24 24 25 26 27

18 unmodified lines

// Params holds the inputs for an incremental relay execution. type Params struct { SourceConn *gitproto.Conn SourceConn gitproto.Conn SourceService interface { FetchPack(ctx context.Context, conn *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) FetchPack(ctx context.Context, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) } TargetPusher interface { PushPack(ctx context.Context, cmds []gitproto.PushCommand, pack io.ReadCloser) error


Minternal/strategy/incremental/incremental.go+2/-2

112 unmodified lines

113 114 115 116 116 117 118 119 120 121 121 122 123 124 54 unmodified lines

179 180 181 182 182 183 184 185 56 unmodified lines

242 243 244 245 245 246 247 248 48 unmodified lines

297 298 299 300 300 301 302 303 40 unmodified lines

344 345 346 347 347 348 349 350

112 unmodified lines

}

type fakeSourceService struct { fetchPack func(context.Context, *gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) fetchPack func(context.Context, gitproto.Conn, map[plumbing.ReferenceName]gitproto.DesiredRef, map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) }

func (f fakeSourceService) FetchPack( ctx context.Context, conn *gitproto.Conn, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash, ) (io.ReadCloser, error) { 54 unmodified lines

params := Params{ SourceService: fakeSourceService{ fetchPack: func(_ context.Context, _ *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { gotDesired = desired gotHaves = targetRefs return io.NopCloser(bytes.NewReader([]byte("PACK"))), nil 56 unmodified lines

params := Params{ SourceService: fakeSourceService{ fetchPack: func(_ context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { gotHaves = targetRefs return io.NopCloser(bytes.NewReader([]byte("PACK"))), nil }, 48 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeSourceService{ fetchPack: func( context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { return pack, nil }, }, 40 unmodified lines

, err := Execute(context.Background(), Params{ SourceService: fakeSourceService{ fetchPack: func( context.Context, _ *gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { return pack, nil }, },


Minternal/strategy/incremental/incremental\_test.go+6/-6

19 unmodified lines

20 21 22 23 23 24 25 25 26 27 28

19 unmodified lines

// Params holds the inputs for a materialized push. type Params struct { Store storer.Storer SourceConn *gitproto.Conn SourceConn gitproto.Conn SourceService interface { FetchToStore(ctx context.Context, store storer.Storer, conn *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) error FetchToStore(ctx context.Context, store storer.Storer, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) error } TargetPusher interface { PushObjects(ctx context.Context, cmds []gitproto.PushCommand, store storer.Storer, hashes []plumbing.Hash) error


Minternal/strategy/materialized/materialized.go+2/-2

16 unmodified lines

17 18 19 20 20 21 22 22 23 24 25

16 unmodified lines

// Params holds the inputs for a replication relay execution. type Params struct { SourceConn *gitproto.Conn SourceConn gitproto.Conn SourceService interface { FetchPack(ctx context.Context, conn *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) FetchPack(ctx context.Context, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, haves map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) } TargetPusher interface { PushPack(ctx context.Context, cmds []gitproto.PushCommand, pack io.ReadCloser) error


Minternal/strategy/replicate/replicate.go+2/-2

12 unmodified lines

13 14 15 16 16 17 18 19 20 21 21 22 23 24 26 unmodified lines

51 52 53 54 54 55 56 57

12 unmodified lines

)

func (f fakeSourceService) FetchPack( ctx context.Context, conn *gitproto.Conn, conn gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash, ) (io.ReadCloser, error) { 26 unmodified lines

result, err := Execute(context.Background(), Params{ SourceService: fakeSourceService{ fetchPack: func(_ context.Context, _ *gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { fetchPack: func(_ context.Context, _ gitproto.Conn, desired map[plumbing.ReferenceName]gitproto.DesiredRef, targetRefs map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) { gotDesired = desired gotHaves = targetRefs return io.NopCloser(bytes.NewReader([]byte("PACK"))), nil


Minternal/strategy/replicate/replicate\_test.go+3/-3

14 unmodified lines

15 16 17 18 19 20 21 26 unmodified lines

48 49 50 50 51 52 53 54 2 unmodified lines

57 58 59 59 60 61 61 62 63 64 65 66 67 68 69 4 unmodified lines

74 75 76 72 77 78 79 80 2 unmodified lines

83 84 85 81 86 87 88 89 90 91 92 84 93 94 86 95 96 97 98

14 unmodified lines

transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"

"entire.io/entire/git-sync/internal/auth" "entire.io/entire/git-sync/internal/gitproto" )

func TestResolveAuthMethodPrefersExplicitToken(t *testing.T) { 26 unmodified lines

} }

func TestNewConnSkipTLSVerify(t *testing.T) { func TestNewHTTPConnSkipTLSVerify(t *testing.T) { stats := newStats(false) conn, err := newConn(Endpoint{ URL: "https://example.com/repo.git", 2 unmodified lines

if err != nil { t.Fatalf("new conn: %v", err) } rt, ok := conn.HTTP.Transport.(*countingRoundTripper) httpConn, ok := conn.(*gitproto.HTTPConn) if !ok { t.Fatalf("expected countingRoundTripper, got %T", conn.HTTP.Transport) t.Fatalf("expected *gitproto.HTTPConn, got %T", conn) } rt, ok := httpConn.HTTP.Transport.(*countingRoundTripper) if !ok { t.Fatalf("expected countingRoundTripper, got %T", httpConn.HTTP.Transport) } base, ok := rt.base.(*http.Transport) if !ok { 4 unmodified lines

} }

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

if err != nil { t.Fatalf("new conn: %v", err) } if conn.HTTP == baseClient { httpConn, ok := conn.(*gitproto.HTTPConn) if !ok { t.Fatalf("expected *gitproto.HTTPConn, got %T", conn) } if httpConn.HTTP == baseClient { t.Fatalf("expected cloned HTTP client, got original pointer") } rt, ok := conn.HTTP.Transport.(*countingRoundTripper) rt, ok := httpConn.HTTP.Transport.(*countingRoundTripper) if !ok { t.Fatalf("expected countingRoundTripper, got %T", conn.HTTP.Transport) t.Fatalf("expected countingRoundTripper, got %T", httpConn.HTTP.Transport) } if rt.base != baseTransport { t.Fatalf("wrapped base transport = %T, want %T", rt.base, baseTransport)


Minternal/syncer/auth\_test.go+16/-7

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 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 96 97 98 99 100 101 102 103 104 105 106

package syncer

import ( "context" "os" "path/filepath" "strings" "testing"

"entire.io/entire/git-sync/internal/gitproto" "github.com/go-git/go-git/v6/plumbing" )

func TestRun_IntegrationSyncOverSSHShimV2(t *testing.T) { root := t.TempDir() sourceBare := filepath.Join(root, "source.git") targetBare := filepath.Join(root, "target.git") worktree := filepath.Join(root, "worktree") logFile := filepath.Join(root, "ssh.log") shim := filepath.Join(root, "ssh-shim.sh")

runGit(t, root, "init", "--bare", sourceBare) runGit(t, root, "init", "--bare", targetBare) runGit(t, root, "init", worktree) runGit(t, worktree, "config", "user.name", "test") runGit(t, worktree, "config", "user.email", "test@example.com") writeFile(t, filepath.Join(worktree, "tracked.txt"), "hello over ssh\n") runGit(t, worktree, "add", "tracked.txt") runGit(t, worktree, "commit", "-m", "initial") runGit(t, worktree, "remote", "add", "origin", sourceBare) runGit(t, worktree, "push", "origin", "HEAD:refs/heads/"+testBranch)

shimBody := strings.Join([]string{ "#!/bin/sh", "if [ "$1" = "-o" ]; then", " shift 2", "fi", "if [ "$1" = "-p" ]; then", " shift 2", "fi", "dest="$1"", "remote="$2"", "printf '%s\t%s\n' "$dest" "$remote" >>" + shSingleQuote(logFile), "exec sh -c "$remote"", }, "\n") if err := os.WriteFile(shim, []byte(shimBody), 0o755); err != nil { t.Fatalf("write ssh shim: %v", err) }

orig := gitproto.SSHLookPath t.Cleanup(func() { gitproto.SSHLookPath = orig }) gitproto.SSHLookPath = func(string) (string, error) { return shim, nil }

result, err := Run(context.Background(), Config{ Source: Endpoint{URL: "ssh://example.com" + sourceBare}, Target: Endpoint{URL: "ssh://example.com" + targetBare}, ProtocolMode: protocolModeAuto, }) if err != nil { t.Fatalf("Run over SSH shim failed: %v", err) } if result.Protocol != protocolModeV2 { t.Fatalf("result.Protocol = %q, want %q", result.Protocol, protocolModeV2) }

assertGitRefEqual(t, sourceBare, targetBare, plumbing.NewBranchReferenceName(testBranch))

data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("read ssh log: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) < 5 { t.Fatalf("expected at least 5 ssh invocations, got %d\n%s", len(lines), string(data)) }

uploadPackCalls := 0 receivePackCalls := 0 v2Calls := 0 for _, line := range lines { switch { case strings.Contains(line, "git-upload-pack"): uploadPackCalls++ case strings.Contains(line, "git-receive-pack"): receivePackCalls++ } if strings.Contains(line, "GIT_PROTOCOL='version=2' git-upload-pack") { v2Calls++ } } if uploadPackCalls < 3 { t.Fatalf("expected repeated source upload-pack RPCs, got %d\n%s", uploadPackCalls, string(data)) } if receivePackCalls < 2 { t.Fatalf("expected target receive-pack discovery + push, got %d\n%s", receivePackCalls, string(data)) } if v2Calls == 0 { t.Fatalf("expected at least one protocol v2 upload-pack call\n%s", string(data)) } }

func shSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", '"'"') + "'" }


Ainternal/syncer/ssh\_integration\_test.go+106

348 unmodified lines

349 350 351 352 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 6 unmodified lines

375 376 377 369 378 379 380 381 158 unmodified lines

540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 2 unmodified lines

567 568 569 542 570 571 572 573 10 unmodified lines

584 585 586 587 588 589 590 591 592 593 594 595 9 unmodified lines

605 606 607 574 608 609 610 611 36 unmodified lines

648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 4 unmodified lines

674 675 676 627 677 678 679 680 681 9 unmodified lines

691 692 693 643 694 695 696 697 698 22 unmodified lines

721 722 723 672 724 725 726

348 unmodified lines

// --- Session setup ---

func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http.Client) (*gitproto.Conn, error) { func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http.Client) (gitproto.Conn, error) { //nolint:ireturn // transport selection intentionally returns the shared connection interface ep, err := transport.ParseURL(raw.URL) if err != nil { return nil, fmt.Errorf("parse endpoint: %w", err) } switch ep.Scheme { case "ssh", "git+ssh": stats.setSideDisplay(label, hostnameFromURL(raw.URL)) conn, err := gitproto.NewSSHConn(ep, label) if err != nil { return nil, fmt.Errorf("new SSH connection: %w", err) } return conn, nil } authEp := auth.Endpoint{ Username: raw.Username, Token: raw.Token, 6 unmodified lines

} stats.setSideDisplay(label, hostnameFromURL(raw.URL)) client := instrumentHTTPClient(httpClient, raw.SkipTLSVerify, label, stats) conn := gitproto.NewConnWithHTTPClient(ep, label, authMethod, client) conn := gitproto.NewHTTPConnWithClient(ep, label, authMethod, client) conn.FollowInfoRefsRedirect = raw.FollowInfoRefsRedirect return conn, nil } 158 unmodified lines

return false }

func sshStatsWarning(cfg Config, sourceConn, targetConn gitproto.Conn) string { if !cfg.Progress && !cfg.ShowStats { return "" } hasSSH := false if _, ok := sourceConn.(*gitproto.SSHConn); ok { hasSSH = true } if !hasSSH && targetConn != nil { if _, ok := targetConn.(*gitproto.SSHConn); ok { hasSSH = true } } if !hasSSH { return "" } return "warning: SSH transport does not yet expose byte-counted throughput; --progress and --stats output will omit SSH transfer bytes" }

// --- Session setup (issue #12) ---

// syncSession holds the shared state for a sync operation, reducing 2 unmodified lines

cfg Config stats *statsCollector logger *slog.Logger sourceConn *gitproto.Conn sourceConn gitproto.Conn sourceService *gitproto.RefService sourceRefMap map[plumbing.ReferenceName]plumbing.Hash target *targetSession 10 unmodified lines

if s.progress != nil { s.progress.terminate() } if s.sourceConn != nil { _ = s.sourceConn.Close() } if s.target != nil && s.target.conn != nil { _ = s.target.conn.Close() } }

// notice surfaces a one-line human-readable event during a sync. When 9 unmodified lines

}

type targetSession struct { conn *gitproto.Conn conn gitproto.Conn adv *packp.AdvRefs refMap map[plumbing.ReferenceName]plumbing.Hash features gitproto.TargetFeatures 36 unmodified lines

stats: newStats(cfg.ShowStats), measurementDone: startMeasurement(cfg.MeasureMemory), } var warnedSSHStats bool warnSSHStats := func(sourceConn, targetConn gitproto.Conn) { if warnedSSHStats { return } warning := sshStatsWarning(cfg, sourceConn, targetConn) if warning == "" { return } warnedSSHStats = true out := cfg.progressOut if out == nil { out = os.Stderr } fmt.Fprintln(out, warning) } if cfg.Verbose { s.logger = slog.New(slog.NewTextHandler(&sessionStderr{s: s}, &slog.HandlerOptions{ Level: slog.LevelInfo, 4 unmodified lines

if err != nil { return nil, fmt.Errorf("create source transport: %w", err) } s.sourceConn.ProgressOut = &sessionStderr{s: s} s.sourceConn.SetProgressWriter(&sessionStderr{s: s}) warnSSHStats(s.sourceConn, nil)

refPrefixes := planner.RefPrefixes(planConfig(cfg)) sourceRefs, sourceService, err := gitproto.ListSourceRefs(ctx, s.sourceConn, cfg.ProtocolMode, refPrefixes) 9 unmodified lines

if err != nil { return nil, fmt.Errorf("create target transport: %w", err) } targetConn.ProgressOut = &sessionStderr{s: s} targetConn.SetProgressWriter(&sessionStderr{s: s}) warnSSHStats(s.sourceConn, targetConn) targetAdv, err := gitproto.AdvertisedRefsV1(ctx, targetConn, transport.ReceivePackService) if err != nil { return nil, fmt.Errorf("list target refs: %w", err) 22 unmodified lines

} } }

// Start the live progress ticker only after auth resolution and the // initial ref-listing round trips have completed. The auth path may // shell out to git credential fill, which inherits our stderr and


Minternal/syncer/syncer.go+58/-7

1 unmodified line

2 3 4 5 6 7 8 9 10 11 12 13 14 15 199 unmodified lines

215 216 217 215 218 219 220 221 222 223 224 222 225 226 227 228 229 230 231 232 1 unmodified line

234 235 236 230 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317

1 unmodified line

import ( "context" "os" "path/filepath" "strings" "testing"

"github.com/go-git/go-git/v6/plumbing"

"entire.io/entire/git-sync/internal/gitproto" bstrap "entire.io/entire/git-sync/internal/strategy/bootstrap" )

199 unmodified lines

// TestNewConn_PropagatesFollowInfoRefsRedirect proves the plumbing from // Endpoint → gitproto.Conn is in place. Without this the flag on // Endpoint is dead config. func TestNewConn_PropagatesFollowInfoRefsRedirect(t *testing.T) { func TestNewHTTPConn_PropagatesFollowInfoRefsRedirect(t *testing.T) { stats := newStats(false)

off, err := newConn(Endpoint{URL: "https://node.example/repo.git"}, "target", stats, nil) if err != nil { t.Fatalf("new conn (off): %v", err) } if off.FollowInfoRefsRedirect { offHTTP, ok := off.(*gitproto.HTTPConn) if !ok { t.Fatalf("expected *gitproto.HTTPConn, got %T", off) } if offHTTP.FollowInfoRefsRedirect { t.Error("FollowInfoRefsRedirect should default to false") }

1 unmodified line

if err != nil { t.Fatalf("new conn (on): %v", err) } if !on.FollowInfoRefsRedirect { onHTTP, ok := on.(*gitproto.HTTPConn) if !ok { t.Fatalf("expected *gitproto.HTTPConn, got %T", on) } if !onHTTP.FollowInfoRefsRedirect { t.Error("FollowInfoRefsRedirect was not propagated from Endpoint to Conn") } }

func TestNewConnBuildsSSHTransport(t *testing.T) { orig := gitproto.SSHLookPath t.Cleanup(func() { gitproto.SSHLookPath = orig }) script := filepath.Join(t.TempDir(), "ssh-stub.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Fatalf("write ssh stub: %v", err) } gitproto.SSHLookPath = func(string) (string, error) { return script, nil }

stats := newStats(false) tests := []string{ "ssh://example.com/repo.git", "git+ssh://example.com/repo.git", "git@example.com:repo.git", } for _, raw := range tests { t.Run(raw, func(t *testing.T) { conn, err := newConn(Endpoint{URL: raw}, "source", stats, nil) if err != nil { t.Fatalf("new conn: %v", err) } if _, ok := conn.(*gitproto.SSHConn); !ok { t.Fatalf("expected *gitproto.SSHConn, got %T", conn) } }) } }

func TestSSHStatsWarning(t *testing.T) { tests := []struct { name string cfg Config source gitproto.Conn target gitproto.Conn want bool }{ { name: "no flags", cfg: Config{}, source: &gitproto.SSHConn{}, }, { name: "http only", cfg: Config{Progress: true}, source: &gitproto.HTTPConn{}, target: &gitproto.HTTPConn{}, }, { name: "progress with ssh source", cfg: Config{Progress: true}, source: &gitproto.SSHConn{}, want: true, }, { name: "show stats with ssh target", cfg: Config{ShowStats: true}, source: &gitproto.HTTPConn{}, target: &gitproto.SSHConn{}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := sshStatsWarning(tt.cfg, tt.source, tt.target) if (got != "") != tt.want { t.Fatalf("sshStatsWarning() = %q, want warning=%t", got, tt.want) } }) } }


Minternal/syncer/syncer\_test.go+87/-3