Merge pull request #3 from entirehq/soph/packagable · Entire

Home

Log in

Merge pull request #3 from entirehq/soph/packagable

5279a4b→main·

Soph·3mo ago·23 files·+1,939 added/-161 removed

Keep the CLI but separate core functionality in a reusable package

Changes

23

59 unmodified lines

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

59 unmodified lines

- `git-sync sync`: execute the planned changes against the target
- `git-sync-bench`: run repeatable benchmark scenarios against fresh empty targets

## Library API

`git-sync` now has a two-tier Go API:

- `pkg/gitsync`
  - stable embedding surface for queue workers and other external callers
  - typed `Probe`, `Plan`, and `Sync` requests/results
  - injected auth and HTTP client support
- `pkg/gitsync/unstable`
  - explicitly non-stable surface for first-party tooling and advanced controls
  - includes `Bootstrap`, `Fetch`, batching and measurement knobs, and CLI-oriented execution options

If you are embedding `git-sync` outside this repo, prefer `pkg/gitsync`. The CLI and benchmark command use `pkg/gitsync/unstable` because they still need direct access to advanced engine controls that are intentionally not part of the stable API.

The stable `pkg/gitsync` results are shaped for workers:

- `Refs`
  - per-ref outcomes
- `Counts`
  - aggregate applied/skipped/blocked/deleted counts
- `Execution`
  - execution mode, protocol, relay summary, and batch summary

See [docs/embedding.md](docs/embedding.md) for worker-oriented guidance.

## Current scope

- Smart HTTP only

MREADME.md+25

14 unmodified lines

15
16
17
18
18
19
20
21
22
23
4 unmodified lines

28
29
30
30
31
32
33
34
35
31
32
33
34
35
36
37
38
39
25 unmodified lines

65
66
67
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
5 unmodified lines

88
89
90
83
91
92
93
94
95
96
97
98
99
92
100
101
102
103
1 unmodified line

105
106
107
100
101
102
103
104
105
106
107
108
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
1 unmodified line

127
128
129
120
121
122
123
124
125
130
127
131
132
133
134
135
136
137
134
138
139
140
141
142
143
137
144
145
146
147
141
148
149
150
151
152
153
154
148
155
156
157
158
11 unmodified lines

170
171
172
166
173
174
175
176
14 unmodified lines

191
192
193
187
188
194
190
195
196
197
198
28 unmodified lines

227
228
229
225
230
231
232
233
228
234
235
236
237
238
239
240
241
242
230
243
244
245
246
247
248
249
250
232
251
252
253
254
193 unmodified lines

448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467

14 unmodified lines

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

"github.com/soph/git-sync/internal/syncer"
    "github.com/soph/git-sync/internal/validation"
    "github.com/soph/git-sync/pkg/gitsync"
    "github.com/soph/git-sync/pkg/gitsync/unstable"
)

type scenario string
4 unmodified lines

)

type runSummary struct {
    Index      int           `json:"index"`
    TargetPath string        `json:"target_path"`
    TargetURL  string        `json:"target_url"`
    WallMillis int64         `json:"wall_millis"`
    Result     syncer.Result `json:"result"`
    Error      string        `json:"error,omitempty"`
    Index      int             `json:"index"`
    TargetPath string          `json:"target_path"`
    TargetURL  string          `json:"target_url"`
    WallMillis int64           `json:"wall_millis"`
    Result     unstable.Result `json:"result"`
    Error      string          `json:"error,omitempty"`
}

type aggregateSummary struct {
25 unmodified lines

Repeat      int              `json:"repeat"`
    KeepTargets bool             `json:"keep_targets"`
    WorkDir     string           `json:"work_dir"`
    Config      syncer.Config    `json:"config"`
    Config      benchmarkConfig  `json:"config"`
    Aggregate   aggregateSummary `json:"aggregate"`
    Runs        []runSummary     `json:"runs"`
}

type benchmarkConfig struct {
    SourceURL string                   `json:"source_url"`
    Scope     gitsync.RefScope         `json:"scope"`
    Policy    gitsync.SyncPolicy       `json:"policy"`
    Options   unstable.AdvancedOptions `json:"options"`
}

func main() {
    if err := run(context.Background(), os.Args[1:]); err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
5 unmodified lines

fs := flag.NewFlagSet("git-sync-bench", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

var cfg syncer.Config
    var scenarioName string
    var workDir string
    var repeat int
    var keepTargets bool
    var jsonOutput bool
    var mappings multiStringFlag
    cfg := benchmarkConfig{}

fs.StringVar(&scenarioName, "scenario", string(scenarioBootstrap), "benchmark scenario: bootstrap or sync")
    fs.StringVar(&cfg.Source.URL, "source-url", "", "source repository URL or local path")
    fs.StringVar(&cfg.SourceURL, "source-url", "", "source repository URL or local path")
    fs.StringVar(&workDir, "work-dir", "", "directory for temporary target repositories")
    fs.IntVar(&repeat, "repeat", 1, "number of runs to execute")
    fs.BoolVar(&keepTargets, "keep-targets", false, "retain generated target repositories after the run")
1 unmodified line

branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.Var(&mappings, "map", "ref mapping in src:dst form; short names map branches, full refs map exact refs")
    fs.BoolVar(&cfg.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&cfg.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
    fs.BoolVar(&cfg.Prune, "prune", false, "delete managed target refs that no longer exist on source")
    fs.BoolVar(&cfg.ShowStats, "stats", false, "collect transfer statistics")
    fs.BoolVar(&cfg.MeasureMemory, "measure-memory", true, "sample elapsed time and Go heap usage")
    fs.Int64Var(&cfg.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&cfg.BatchMaxPackBytes, "batch-max-pack-bytes", 0, "split branch bootstrap into relay batches capped at this many bytes per batch")
    fs.StringVar(&cfg.ProtocolMode, "protocol", validation.ProtocolAuto, "protocol mode: auto, v1, or v2")
    fs.BoolVar(&cfg.Verbose, "v", false, "verbose logging")
    fs.BoolVar(&cfg.Policy.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&cfg.Policy.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
    fs.BoolVar(&cfg.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
    fs.BoolVar(&cfg.Options.CollectStats, "stats", false, "collect transfer statistics")
    fs.BoolVar(&cfg.Options.MeasureMemory, "measure-memory", true, "sample elapsed time and Go heap usage")
    fs.Int64Var(&cfg.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&cfg.Options.BatchMaxPackBytes, "batch-max-pack-bytes", 0, "split branch bootstrap into relay batches capped at this many bytes per batch")
    benchProtocol := benchProtocolModeFlag(benchProtocolMode(validation.ProtocolAuto))
    fs.Var(&benchProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&cfg.Options.Verbose, "v", false, "verbose logging")

if err := fs.Parse(args); err != nil {
        return err
    }
    cfg.Policy.Protocol = gitsync.ProtocolMode(benchProtocol)
    if len(fs.Args()) > 0 {
        return usageError("unexpected positional arguments")
    }
1 unmodified line

return usageError("--repeat must be at least 1")
    }

mode, err := validation.NormalizeProtocolMode(cfg.ProtocolMode)
    if err != nil {
        return err
    }
    cfg.ProtocolMode = mode

if *branches != "" {
        cfg.Branches = splitCSV(*branches)
        cfg.Scope.Branches = splitCSV(*branches)
    }
    for _, raw := range mappings {
        mapping, err := validation.ParseMapping(raw)
        if err != nil {
            return err
        }
        cfg.Mappings = append(cfg.Mappings, mapping)
        cfg.Scope.Mappings = append(cfg.Scope.Mappings, gitsync.RefMapping{
            Source: mapping.Source,
            Target: mapping.Target,
        })
    }

srcURL, err := normalizeRepoURL(cfg.Source.URL)
    srcURL, err := normalizeRepoURL(cfg.SourceURL)
    if err != nil {
        return err
    }
    cfg.Source.URL = srcURL
    cfg.SourceURL = srcURL

sc, err := parseScenario(scenarioName)
    if err != nil {
        return err
    }
    if sc == scenarioBootstrap {
        if cfg.Force || cfg.Prune {
        if cfg.Policy.Force || cfg.Policy.Prune {
            return usageError("bootstrap benchmarks do not support --force or --prune")
        }
    }
11 unmodified lines

report := benchmarkReport{
        Scenario:    sc,
        SourceURL:   cfg.Source.URL,
        SourceURL:   cfg.SourceURL,
        Repeat:      repeat,
        KeepTargets: keepTargets,
        WorkDir:     workDir,
14 unmodified lines

if err != nil {
            return err
        }
        runCfg.Target.URL = targetURL

start := time.Now()
        runResult, runErr := executeScenario(ctx, sc, runCfg)
        runResult, runErr := executeScenario(ctx, sc, runCfg, targetURL)
        summary := runSummary{
            Index:      i + 1,
            TargetPath: targetPath,
28 unmodified lines

return nil
}

func executeScenario(ctx context.Context, sc scenario, cfg syncer.Config) (syncer.Result, error) {
func executeScenario(ctx context.Context, sc scenario, cfg benchmarkConfig, targetURL string) (unstable.Result, error) {
    client := unstable.New(unstable.Options{})
    switch sc {
    case scenarioBootstrap:
        return syncer.Bootstrap(ctx, cfg)
        return client.Bootstrap(ctx, unstable.BootstrapRequest{
            Source:      gitsync.Endpoint{URL: cfg.SourceURL},
            Target:      gitsync.Endpoint{URL: targetURL},
            Scope:       cfg.Scope,
            IncludeTags: cfg.Policy.IncludeTags,
            Protocol:    cfg.Policy.Protocol,
            Options:     cfg.Options,
        })
    case scenarioSync:
        return syncer.Run(ctx, cfg)
        return client.Sync(ctx, unstable.SyncRequest{
            Source:  gitsync.Endpoint{URL: cfg.SourceURL},
            Target:  gitsync.Endpoint{URL: targetURL},
            Scope:   cfg.Scope,
            Policy:  cfg.Policy,
            Options: cfg.Options,
        })
    default:
        return syncer.Result{}, fmt.Errorf("unsupported scenario %q", sc)
        return unstable.Result{}, fmt.Errorf("unsupported scenario %q", sc)
    }
}

193 unmodified lines

}
    return fmt.Errorf("%s\n\n%s", message, usage)
}

type benchProtocolMode gitsync.ProtocolMode

type benchProtocolModeFlag benchProtocolMode

func (p *benchProtocolModeFlag) String() string {
    return string(*p)
}

func (p *benchProtocolModeFlag) Set(value string) error {
    mode, err := validation.NormalizeProtocolMode(value)
    if err != nil {
        return err
    }
    *p = benchProtocolModeFlag(benchProtocolMode(gitsync.ProtocolMode(mode)))
    return nil
}

Mcmd/git-sync-bench/main.go+74/-38

2 unmodified lines

3
4
5
6
6
7
8
9
10
11
12
13
13
14
15
16
16
17
18
19
4 unmodified lines

24
25
26
27
27
28
29
30
31
32
32
33
34
35

2 unmodified lines

import (
    "testing"

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

func TestSummarizeRuns(t *testing.T) {
    runs := []runSummary{
        {
            WallMillis: 100,
            Result: syncer.Result{
            Result: unstable.Result{
                RelayMode: "bootstrap",
                Batching:  false,
                Measurement: syncer.Measurement{
                Measurement: unstable.Measurement{
                    ElapsedMillis:      90,
                    PeakAllocBytes:     10,
                    PeakHeapInuseBytes: 20,
4 unmodified lines

},
        {
            WallMillis: 140,
            Result: syncer.Result{
            Result: unstable.Result{
                RelayMode:         "bootstrap-batch",
                Batching:          true,
                BatchCount:        3,
                PlannedBatchCount: 4,
                Measurement: syncer.Measurement{
                Measurement: unstable.Measurement{
                    ElapsedMillis:      130,
                    PeakAllocBytes:     50,
                    PeakHeapInuseBytes: 60,

Mcmd/git-sync-bench/main_test.go+5/-5

9 unmodified lines

10
11
12
13
13
14
15
16
17
18
30 unmodified lines

49
50
51
51
52
53
54
55
56
57
55
56
58
59
60
58
59
60
61
62
63
61
62
63
64
65
66
67
65
66
68
69
70
71
72
70
71
72
73
74
73
74
75
76
77
78
76
77
78
79
80
81
82
83
84
85
86
87
88
89
85
86
90
91
92
88
89
93
94
95
96
97
98
99
100
96
101
102
103
104
105
106
107
103
108
109
110
111
112
113
106
114
115
116
117
110
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
9 unmodified lines

142
143
144
126
145
146
147
148
149
150
130
131
151
152
153
133
134
135
136
137
138
154
155
156
157
158
159
160
140
141
161
162
163
164
165
145
146
147
166
167
168
169
149
150
151
152
170
171
172
173
174
175
176
177
178
179
180
181
159
160
182
183
184
162
163
185
186
187
188
189
190
191
192
170
193
194
195
196
197
198
199
177
200
201
202
203
204
205
180
206
207
208
209
184
210
211
212
213
214
215
5 unmodified lines

221
222
223
196
224
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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
219
220
251
252
253
222
223
254
255
256
257
258
259
228
260
261
262
263
264
265
266
232
267
268
269
270
271
272
5 unmodified lines

278
279
280
244
281
282
283
284
285
286
249
250
251
252
253
287
288
289
290
291
292
255
256
257
258
293
294
295
296
297
298
299
300
1 unmodified line

302
303
304
305
306
307
268
269
308
309
310
311
312
313
274
314
315
316
317
278
318
319
320
321
5 unmodified lines

327
328
329
290
330
331
332
333
334
335
336
337
58 unmodified lines

396
397
398
355
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421

9 unmodified lines

"strings"

"github.com/go-git/go-git/v6/plumbing"
    "github.com/soph/git-sync/internal/syncer"
    "github.com/soph/git-sync/internal/validation"
    "github.com/soph/git-sync/pkg/gitsync"
    "github.com/soph/git-sync/pkg/gitsync/unstable"
)

func main() {
30 unmodified lines

fs := flag.NewFlagSet(name, flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

cfg := syncer.Config{DryRun: dryRun}
    var mappings multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    req := unstable.SyncRequest{DryRun: dryRun}

fs.StringVar(&cfg.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&cfg.Target.URL, "target-url", "", "target repository URL")
    fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&req.Target.URL, "target-url", "", "target repository URL")

fs.StringVar(&cfg.Source.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&cfg.Target.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&cfg.Source.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&cfg.Target.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.BoolVar(&cfg.Source.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&cfg.Target.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")
    fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&targetAuth.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&targetAuth.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")

fs.StringVar(&cfg.Source.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.StringVar(&cfg.Target.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")
    fs.StringVar(&sourceAuth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.StringVar(&targetAuth.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")

if err := fs.Parse(args); err != nil {
        return err
    }
    req.Policy.Protocol = gitsync.ProtocolMode(protocolValue)

positional := fs.Args()
    if cfg.Source.URL == "" && len(positional) > 0 {
        cfg.Source.URL = positional[0]
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if cfg.Target.URL == "" && len(positional) > 1 {
        cfg.Target.URL = positional[1]
    if req.Target.URL == "" && len(positional) > 1 {
        req.Target.URL = positional[1]
    }
    if len(positional) > 2 {
        return usageError("too many positional arguments")
    }

if *branches != "" {
        cfg.Branches = splitCSV(*branches)
        req.Scope.Branches = splitCSV(*branches)
    }
    for _, raw := range mappings {
        mapping, err := validation.ParseMapping(raw)
        if err != nil {
            return err
        }
        cfg.Mappings = append(cfg.Mappings, mapping)
        req.Scope.Mappings = append(req.Scope.Mappings, gitsync.RefMapping{
            Source: mapping.Source,
            Target: mapping.Target,
        })
    }

if cfg.Source.URL == "" || cfg.Target.URL == "" {
    if req.Source.URL == "" || req.Target.URL == "" {
        return usageError(name + " requires source and target repository URLs")
    }

result, err := syncer.Run(ctx, cfg)
    client := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    })
    var (
        result unstable.Result
        err    error
    )
    if dryRun {
        result, err = client.Plan(ctx, req)
    } else {
        result, err = client.Sync(ctx, req)
    }
    if err != nil {
        return err
    }
9 unmodified lines

fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

cfg := syncer.Config{}
    var mappings multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    req := unstable.BootstrapRequest{}

branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.Var(&mappings, "map", "ref mapping in src:dst form; short names map branches, full refs map exact refs")
    fs.BoolVar(&cfg.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&cfg.ShowStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&cfg.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&req.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")
    fs.Int64Var(&cfg.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&cfg.BatchMaxPackBytes, "batch-max-pack-bytes", 0, "split branch bootstrap into relay batches capped at this many bytes per batch")
    fs.StringVar(&cfg.ProtocolMode, "protocol", envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto), "protocol mode: auto, v1, or v2")
    fs.BoolVar(&cfg.Verbose, "v", false, "verbose logging")
    fs.Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&req.Options.BatchMaxPackBytes, "batch-max-pack-bytes", 0, "split branch bootstrap into relay batches capped at this many bytes per batch")
    bootstrapProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&bootstrapProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.Verbose, "v", false, "verbose logging")

if err := fs.Parse(args); err != nil {
        return err
    }
    req.Protocol = gitsync.ProtocolMode(bootstrapProtocol)

if cfg.Source.URL == "" || cfg.Target.URL == "" {
    if req.Source.URL == "" || req.Target.URL == "" {
        return usageError("bootstrap requires source and target repository URLs")
    }

result, err := syncer.Bootstrap(ctx, cfg)
    result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    }).Bootstrap(ctx, req)
    if err != nil {
        return err
    }
5 unmodified lines

fs := flag.NewFlagSet("probe", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

cfg := syncer.Config{}
    var jsonOutput bool
    fs.StringVar(&cfg.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&cfg.Target.URL, "target-url", "", "optional target repository URL")
    fs.StringVar(&cfg.Source.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&cfg.Target.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&cfg.Source.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&cfg.Target.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.StringVar(&cfg.Source.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.StringVar(&cfg.Target.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")
    fs.BoolVar(&cfg.Source.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&cfg.Target.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")
    fs.BoolVar(&cfg.IncludeTags, "tags", false, "include tag ref prefixes in probe")
    fs.StringVar(&cfg.ProtocolMode, "protocol", envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto), "protocol mode: auto, v1, or v2")
    fs.BoolVar(&cfg.ShowStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&cfg.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    req := unstable.ProbeRequest{}
    fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    targetURL := fs.String("target-url", "", "optional target repository URL")
    fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&targetAuth.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.StringVar(&sourceAuth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.StringVar(&targetAuth.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&targetAuth.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")
    fs.BoolVar(&req.IncludeTags, "tags", false, "include tag ref prefixes in probe")
    probeProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&probeProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")

if err := fs.Parse(args); err != nil {
        return err
    }
    req.Protocol = gitsync.ProtocolMode(probeProtocol)

positional := fs.Args()
    if cfg.Source.URL == "" && len(positional) > 0 {
        cfg.Source.URL = positional[0]
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if cfg.Target.URL == "" && len(positional) > 1 {
        cfg.Target.URL = positional[1]
    if *targetURL == "" && len(positional) > 1 {
        *targetURL = positional[1]
    }
    if len(positional) > 2 {
        return usageError("too many positional arguments")
    }
    if cfg.Source.URL == "" {
    if req.Source.URL == "" {
        return usageError("probe requires a source repository URL")
    }
    if *targetURL != "" {
        req.Target = &gitsync.Endpoint{URL: *targetURL}
    }

result, err := syncer.Probe(ctx, cfg)
    result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    }).Probe(ctx, req)
    if err != nil {
        return err
    }
5 unmodified lines

fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

cfg := syncer.Config{}
    var haveRefs multiStringFlag
    var haveHashesRaw multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    req := unstable.FetchRequest{}

fs.StringVar(&cfg.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&cfg.Source.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&cfg.Source.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&cfg.Source.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.BoolVar(&cfg.Source.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&sourceAuth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.BoolVar(&cfg.IncludeTags, "tags", false, "include tags in the fetch request")
    fs.StringVar(&cfg.ProtocolMode, "protocol", envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto), "protocol mode: auto, v1, or v2")
    fs.BoolVar(&cfg.ShowStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&cfg.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&req.IncludeTags, "tags", false, "include tags in the fetch request")
    fetchProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&fetchProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")
    fs.Var(&haveRefs, "have-ref", "source ref name to advertise as have; short names map to branches")
    fs.Var(&haveHashesRaw, "have", "explicit object hash to advertise as have")
1 unmodified line

if err := fs.Parse(args); err != nil {
        return err
    }
    req.Protocol = gitsync.ProtocolMode(fetchProtocol)

positional := fs.Args()
    if cfg.Source.URL == "" && len(positional) > 0 {
        cfg.Source.URL = positional[0]
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if len(positional) > 1 {
        return usageError("too many positional arguments")
    }
    if cfg.Source.URL == "" {
    if req.Source.URL == "" {
        return usageError("fetch requires a source repository URL")
    }
    if *branches != "" {
        cfg.Branches = splitCSV(*branches)
        req.Scope.Branches = splitCSV(*branches)
    }

haveHashes := make([]plumbing.Hash, 0, len(haveHashesRaw))
5 unmodified lines

haveHashes = append(haveHashes, hash)
    }

result, err := syncer.Fetch(ctx, cfg, haveRefs, haveHashes)
    req.HaveRefs = append(req.HaveRefs, haveRefs...)
    req.HaveHashes = append(req.HaveHashes, haveHashes...)
    result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth},
    }).Fetch(ctx, req)
    if err != nil {
        return err
    }
58 unmodified lines

}

func usageError(message string) error {
    usage := fmt.Sprintf("usage:\n  git-sync sync [flags] <source-url> <target-url>\n  git-sync plan [flags] <source-url> <target-url>\n  git-sync bootstrap [flags] <source-url> <target-url>\n  git-sync probe [flags] <source-url> [target-url]\n  git-sync fetch [flags] <source-url>\n\nsync flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --force\n  --prune\n  --stats\n  --measure-memory\n  --json\n  --materialized-max-objects %d\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nplan flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --force\n  --prune\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nbootstrap flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --max-pack-bytes 104857600\n  --batch-max-pack-bytes 1073741824\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nprobe flags:\n  --tags\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --source-username git\n  --source-bearer-token ...\n  --target-token ...\n  --target-username git\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n\nfetch flags:\n  --branch main,dev\n  --tags\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --have-ref main\n  --have <hash>\n  --source-token ...\n  --source-username git\n  --source-bearer-token ...\n  --source-insecure-skip-tls-verify\n", syncer.DefaultMaterializedMaxObjects)
    usage := fmt.Sprintf("usage:\n  git-sync sync [flags] <source-url> <target-url>\n  git-sync plan [flags] <source-url> <target-url>\n  git-sync bootstrap [flags] <source-url> <target-url>\n  git-sync probe [flags] <source-url> [target-url]\n  git-sync fetch [flags] <source-url>\n\nsync flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --force\n  --prune\n  --stats\n  --measure-memory\n  --json\n  --materialized-max-objects %d\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nplan flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --force\n  --prune\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nbootstrap flags:\n  --branch main,dev\n  --map main:stable\n  --tags\n  --max-pack-bytes 104857600\n  --batch-max-pack-bytes 1073741824\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --target-token ...\n  --source-username git\n  --target-username git\n  --source-bearer-token ...\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n  -v\n\nprobe flags:\n  --tags\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --source-token ...\n  --source-username git\n  --source-bearer-token ...\n  --target-token ...\n  --target-username git\n  --target-bearer-token ...\n  --source-insecure-skip-tls-verify\n  --target-insecure-skip-tls-verify\n\nfetch flags:\n  --branch main,dev\n  --tags\n  --stats\n  --measure-memory\n  --json\n  --protocol auto|v1|v2\n  --have-ref main\n  --have <hash>\n  --source-token ...\n  --source-username git\n  --source-bearer-token ...\n  --source-insecure-skip-tls-verify\n", unstable.DefaultMaterializedMaxObjects)
    if message == "" {
        return errors.New(strings.TrimSpace(usage))
    }
    return fmt.Errorf("%s\n\n%s", message, usage)
}

type protocolMode gitsync.ProtocolMode

type protocolModeFlag protocolMode

func (p *protocolModeFlag) String() string {
    return string(*p)
}

func (p *protocolModeFlag) Set(value string) error {
    mode, err := validation.NormalizeProtocolMode(value)
    if err != nil {
        return err
    }
    *p = protocolModeFlag(protocolMode(gitsync.ProtocolMode(mode)))
    return nil
}

Mcmd/git-sync/main.go+152/-91

21 unmodified lines

22
23
24
25
25
26
27
28
29
30
31
31
32
33
34
35
35
36
37
38
38
39
40
41

21 unmodified lines

"github.com/go-git/go-git/v6/plumbing/transport"
    transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"
    "github.com/go-git/go-git/v6/storage/memory"
    "github.com/soph/git-sync/internal/syncer"
    "github.com/soph/git-sync/pkg/gitsync/unstable"
)

const testBranch = "master"

func TestMarshalOutput_JSONShape(t *testing.T) {
    data, err := marshalOutput(syncer.FetchResult{
    data, err := marshalOutput(unstable.FetchResult{
        SourceURL:      "https://example.com/source.git",
        RequestedMode:  "auto",
        Protocol:       "v2",
        Wants:          []syncer.RefInfo{{Name: "refs/heads/main", Hash: plumbing.NewHash("1111111111111111111111111111111111111111")}},
        Wants:          []unstable.RefInfo{{Name: "refs/heads/main", Hash: plumbing.NewHash("1111111111111111111111111111111111111111")}},
        Haves:          []plumbing.Hash{plumbing.NewHash("2222222222222222222222222222222222222222")},
        FetchedObjects: 42,
        Measurement: syncer.Measurement{
        Measurement: unstable.Measurement{
            Enabled:            true,
            ElapsedMillis:      12,
            PeakAllocBytes:     100,

Mcmd/git-sync/main_test.go+4/-4

1
2
3
3
4
5
6
73 unmodified lines

80
81
82
83
84
85
86
87
88
89
90
91
92
13 unmodified lines

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
50 unmodified lines

186
187
188
189

# Architecture

`git-sync` is a remote-to-remote Git mirroring CLI over smart HTTP.
`git-sync` is a remote-to-remote Git mirroring tool and library over smart HTTP.

## Product Rationale

73 unmodified lines

## Package Model

- `pkg/gitsync`
  - stable public embedding API
  - typed `Probe`, `Plan`, and `Sync` requests/results
  - auth and HTTP client injection for worker-style callers
- `pkg/gitsync/unstable`
  - explicitly non-stable first-party tooling surface
  - advanced controls, `Bootstrap`, `Fetch`, and CLI-oriented knobs
- `internal/gitproto`
  - smart HTTP, pkt-line, fetch/push request handling, capability negotiation
- `internal/planner`
13 unmodified lines

- `internal/syncertest`
  - shared in-memory test fixtures

## Public API Boundary

The project now separates embedding concerns from first-party tooling concerns:

- `pkg/gitsync` is the stable library boundary.
  Callers express orchestration intent through typed probe, plan, and sync requests. Auth and transport are injected. Execution strategy remains internal.
- `pkg/gitsync/unstable` is the escape hatch for advanced controls.
  It exists so the CLI and benchmark tool can use batching limits, memory measurement, verbose progress, bootstrap, and fetch without widening the stable API prematurely.

The stable result contract is also intentionally worker-oriented:

- `Refs`
  per-ref outcomes and reasons
- `Counts`
  aggregate applied/skipped/blocked/deleted counts
- `Execution`
  protocol, relay summary, execution mode, and batch summary

That split is intentional:

- external embedders should depend on `pkg/gitsync`
- first-party tools inside this repo may use `pkg/gitsync/unstable`
- strategy selection, batching heuristics, and materialized fallback controls are not yet treated as stable product contracts

## Protocol Boundaries

- Source discovery and source fetch can use protocol v2 when supported.
50 unmodified lines

- [bootstrap.md](bootstrap.md)
- [bootstrap-batching.md](bootstrap-batching.md)
- [benchmarking.md](benchmarking.md)
- [embedding.md](embedding.md)

Mdocs/architecture.md+33/-1

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

# Embedding

`git-sync` can now be used as a library as well as a CLI.

For most embedders, there are two important rules:

- use `pkg/gitsync`
- avoid depending on `pkg/gitsync/unstable` unless you are acting like first-party tooling

## Stable vs Unstable

Use `pkg/gitsync` when you want a durable worker-facing API:

- `Probe`
- `Plan`
- `Sync`
- typed requests and results
- injected auth and HTTP client support

Use `pkg/gitsync/unstable` only when you need controls that are intentionally not yet stable:

- `Bootstrap`
- `Fetch`
- batching knobs
- heap measurement
- verbose execution controls
- other engine-adjacent tuning

The CLI and benchmark command use `pkg/gitsync/unstable` because they still need those controls. External workers should generally not.

## Worker Shape

A queue worker usually wants:

1. deserialize a job into source, target, scope, and policy
2. build a `gitsync.Client`
3. inject auth and an `http.Client`
4. call `Plan` or `Sync`
5. persist structured result data
6. decide success, retry, or escalation

Minimal example:

```go
package worker

import (
    "context"
    "net/http"

"github.com/soph/git-sync/pkg/gitsync"
)

func runSync(ctx context.Context) error {
    client := gitsync.New(gitsync.Options{
        HTTPClient: &http.Client{},
        Auth: gitsync.StaticAuthProvider{
            Source: gitsync.EndpointAuth{Token: "source-token"},
            Target: gitsync.EndpointAuth{Token: "target-token"},
        },
    })

result, err := client.Sync(ctx, gitsync.SyncRequest{
        Source: gitsync.Endpoint{URL: "https://github.example/source/repo.git"},
        Target: gitsync.Endpoint{URL: "https://git.example/target/repo.git"},
        Scope: gitsync.RefScope{
            Branches: []string{"main"},
        },
        Policy: gitsync.SyncPolicy{
            IncludeTags: true,
            Protocol:    gitsync.ProtocolAuto,
        },
    })
    if err != nil {
        return err
    }

_ = result
    return nil
}
```

## Auth Injection

`pkg/gitsync` uses one auth ownership model:

- requests carry endpoint identity
- `AuthProvider` resolves source and target auth

That avoids baking CLI-style precedence rules into request types.

Good uses of `AuthProvider`:

- resolve OAuth tokens from your worker secret store
- attach different credentials for source and target
- centralize token refresh or lookup logic

The simplest option is `gitsync.StaticAuthProvider`, but a real worker will usually implement `AuthProvider` itself.

## HTTP Injection

Pass an `*http.Client` through `gitsync.Options` when you need:

- explicit timeouts
- custom TLS or proxy config
- OTEL or tracing round-trippers
- test transports
- custom connection pooling behavior

`git-sync` clones and wraps the provided client internally so it can still collect transfer stats without mutating the caller's client directly.

## Result Handling

The stable `SyncResult` is organized for worker consumption:

- `Refs`
  per-ref outcomes and reasons
- `Counts`
  aggregate applied/skipped/blocked/deleted totals
- `Execution`
  protocol, relay summary, execution mode, and batch summary
- `Stats`
  transfer counters when requested
- `Measurement`
  only where exposed by the stable surface

That gives a worker enough structure to:

- persist job history
- emit metrics
- log ref-level outcomes
- make retry/escalation decisions

## Retry Guidance

Treat these differently:

- request construction and auth errors
  Usually configuration or secret-resolution issues. Retry only if your system expects credentials to become valid asynchronously.
- transport or remote errors returned from `Sync`
  Usually retryable depending on your queue policy and remote failure mode.
- successful `SyncResult` with blocked refs
  This is usually not a transport retry. It is a policy or repo-state outcome and should often be surfaced to operators.

For many workers, a useful pattern is:

- retry on returned `error`
- do not blindly retry on `Counts.Blocked > 0`
- log `Execution.Mode` and `Execution.Reason` for operator visibility

## What Not To Depend On

If you want stability, do not build external worker logic around:

- batching thresholds
- max-pack controls
- materialized-object limits
- temp refs
- exact relay strategy names beyond coarse execution summary

Those are implementation details or advanced controls that currently belong in `pkg/gitsync/unstable`, not the stable embedding contract.

Adocs/embedding.md+161

49 unmodified lines

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

49 unmodified lines

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

// NewConnWithHTTPClient creates a new connection using the provided HTTP client.
// 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 *transport.Endpoint, label string, auth transport.AuthMethod, httpClient *http.Client) *Conn {
    if httpClient == nil {
        httpClient = &http.Client{Transport: http.DefaultTransport}
    }
    return &Conn{
        Label:     label,
        Endpoint:  ep,

Minternal/gitproto/smarthttp.go+10

51 unmodified lines

52
53
54
55
55
56
57
58
10 unmodified lines

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

193
194
195
175

51 unmodified lines

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

}
}

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

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

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

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

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

514 unmodified lines

515
516
517
518
518
519
520
521
90 unmodified lines

612
613
614
615
615
616
617
618
13 unmodified lines

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

514 unmodified lines

}

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

}

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

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

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

8 unmodified lines

9
10
11
12
13
14
15
39 unmodified lines

55
56
57
58
59
60
61
211 unmodified lines

273
274
275
274
276
277
278
279
8 unmodified lines

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

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

362
363
364
351
365
366
367
368
7 unmodified lines

376
377
378
365
379
380
381
382

8 unmodified lines

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

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

// --- Session setup ---

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

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

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

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

}

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

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

}))
    }

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

s.sourceRefMap = gitproto.RefHashMap(sourceRefs)

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

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

7 unmodified lines

8
9
10
11
11
12
13
14
8 unmodified lines

23
24
25
26
26
27
28
29
1 unmodified line

31
32
33
34

7 unmodified lines

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

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

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

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

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

package gitsync

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

"github.com/soph/git-sync/internal/validation"
    "github.com/soph/git-sync/pkg/gitsync/internalbridge"
)

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

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

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

// Probe inspects a source remote and optional target remote.
func (c *Client) Probe(ctx context.Context, req ProbeRequest) (ProbeResult, error) {
    if err := req.Validate(); err != nil {
        return ProbeResult{}, err
    }
    cfg, err := c.buildProbeConfig(ctx, req)
    if err != nil {
        return ProbeResult{}, err
    }
    result, err := internalbridge.Probe(ctx, cfg)
    if err != nil {
        return ProbeResult{}, err
    }
    return internalbridge.FromProbeResult(result), nil
}

// Plan computes ref actions without pushing.
func (c *Client) Plan(ctx context.Context, req PlanRequest) (PlanResult, error) {
    if err := req.Validate(); err != nil {
        return PlanResult{}, err
    }
    cfg, err := c.buildSyncConfig(ctx, req.Source, req.Target, req.Scope, req.Policy, req.CollectStats, true)
    if err != nil {
        return PlanResult{}, err
    }
    result, err := internalbridge.Run(ctx, cfg)
    if err != nil {
        return PlanResult{}, err
    }
    return internalbridge.FromSyncResult(result), nil
}

// Sync executes a sync between two remotes.
func (c *Client) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) {
    if err := req.Validate(); err != nil {
        return SyncResult{}, err
    }
    cfg, err := c.buildSyncConfig(ctx, req.Source, req.Target, req.Scope, req.Policy, req.CollectStats, false)
    if err != nil {
        return SyncResult{}, err
    }
    result, err := internalbridge.Run(ctx, cfg)
    if err != nil {
        return SyncResult{}, err
    }
    return internalbridge.FromSyncResult(result), nil
}

func (c *Client) buildProbeConfig(ctx context.Context, req ProbeRequest) (internalbridge.Config, error) {
    sourceAuth, err := c.authFor(ctx, req.Source, SourceRole)
    if err != nil {
        return internalbridge.Config{}, err
    }
    var target *internalbridge.Endpoint
    targetAuth := internalbridge.EndpointAuth{}
    if req.Target != nil {
        resolvedTargetAuth, err := c.authFor(ctx, *req.Target, TargetRole)
        if err != nil {
            return internalbridge.Config{}, err
        }
        target = ptr(bridgeEndpoint(*req.Target))
        targetAuth = bridgeEndpointAuth(resolvedTargetAuth)
    }
    return internalbridge.ProbeConfig(
        bridgeEndpoint(req.Source),
        bridgeEndpointAuth(sourceAuth),
        target,
        targetAuth,
        internalbridge.ProtocolMode(req.Protocol),
        req.IncludeTags,
        req.CollectStats,
        c.httpClient,
    ), nil
}

func (c *Client) buildSyncConfig(ctx context.Context, source Endpoint, target Endpoint, scope RefScope, policy SyncPolicy, collectStats, dryRun bool) (internalbridge.Config, error) {
    sourceAuth, err := c.authFor(ctx, source, SourceRole)
    if err != nil {
        return internalbridge.Config{}, err
    }
    targetAuth, err := c.authFor(ctx, target, TargetRole)
    if err != nil {
        return internalbridge.Config{}, err
    }
    return internalbridge.SyncConfig(
        bridgeEndpoint(source),
        bridgeEndpointAuth(sourceAuth),
        bridgeEndpoint(target),
        bridgeEndpointAuth(targetAuth),
        bridgeScope(scope),
        bridgePolicy(policy),
        collectStats,
        dryRun,
        c.httpClient,
    ), nil
}

func (c *Client) authFor(ctx context.Context, endpoint Endpoint, role EndpointRole) (EndpointAuth, error) {
    if c.auth == nil {
        return EndpointAuth{}, nil
    }
    return c.auth.AuthFor(ctx, endpoint, role)
}

func (r SyncRequest) Validate() error {
    if r.Source.URL == "" {
        return fmt.Errorf("source URL is required")
    }
    if r.Target.URL == "" {
        return fmt.Errorf("target URL is required")
    }
    if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
        return err
    }
    if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings)); err != nil {
        return err
    }
    return nil
}

func (r PlanRequest) Validate() error {
    if r.Source.URL == "" {
        return fmt.Errorf("source URL is required")
    }
    if r.Target.URL == "" {
        return fmt.Errorf("target URL is required")
    }
    if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
        return err
    }
    if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings)); err != nil {
        return err
    }
    return nil
}

func (r ProbeRequest) Validate() error {
    if r.Source.URL == "" {
        return fmt.Errorf("source URL is required")
    }
    if r.Target != nil && r.Target.URL == "" {
        return fmt.Errorf("target URL is required when target endpoint is provided")
    }
    if _, err := validation.NormalizeProtocolMode(string(r.Protocol)); err != nil {
        return err
    }
    return nil
}

func bridgeEndpoint(ep Endpoint) internalbridge.Endpoint {
    return internalbridge.Endpoint{URL: ep.URL}
}

func bridgeEndpointAuth(auth EndpointAuth) internalbridge.EndpointAuth {
    return internalbridge.EndpointAuth{
        Username:      auth.Username,
        Token:         auth.Token,
        BearerToken:   auth.BearerToken,
        SkipTLSVerify: auth.SkipTLSVerify,
    }
}

func bridgeScope(scope RefScope) internalbridge.RefScope {
    mappings := make([]internalbridge.RefMapping, 0, len(scope.Mappings))
    for _, mapping := range scope.Mappings {
        mappings = append(mappings, internalbridge.RefMapping{
            Source: mapping.Source,
            Target: mapping.Target,
        })
    }
    return internalbridge.RefScope{
        Branches: append([]string(nil), scope.Branches...),
        Mappings: mappings,
    }
}

func bridgePolicy(policy SyncPolicy) internalbridge.SyncPolicy {
    return internalbridge.SyncPolicy{
        IncludeTags: policy.IncludeTags,
        Force:       policy.Force,
        Prune:       policy.Prune,
        Protocol:    internalbridge.ProtocolMode(policy.Protocol),
    }
}

func ptr[T any](v T) *T {
    return &v
}

func validationMappings(mappings []RefMapping) []validation.RefMapping {
    out := make([]validation.RefMapping, 0, len(mappings))
    for _, mapping := range mappings {
        out = append(out, validation.RefMapping{
            Source: mapping.Source,
            Target: mapping.Target,
        })
    }
    return out
}

Apkg/gitsync/client.go+227

package gitsync

import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "testing"

git "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/protocol/packp" "github.com/go-git/go-git/v6/plumbing/transport"

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

type errAuthProvider struct{}

func (errAuthProvider) AuthFor(_ context.Context, _ Endpoint, _ EndpointRole) (EndpointAuth, error) { return EndpointAuth{}, fmt.Errorf("boom") }

func TestValidateRequests(t *testing.T) { if err := (ProbeRequest{}).Validate(); err == nil { t.Fatalf("expected probe validation error") } if err := (PlanRequest{}).Validate(); err == nil { t.Fatalf("expected plan validation error") } if err := (SyncRequest{}).Validate(); err == nil { t.Fatalf("expected sync validation error") } if err := (ProbeRequest{ Source: Endpoint{URL: "https://source.example/repo.git"}, Protocol: "bogus", }).Validate(); err == nil { t.Fatalf("expected invalid probe protocol validation error") } if err := (SyncRequest{ Source: Endpoint{URL: "https://source.example/repo.git"}, Target: Endpoint{URL: "https://target.example/repo.git"}, Policy: SyncPolicy{Protocol: "bogus"}, }).Validate(); err == nil { t.Fatalf("expected invalid sync protocol validation error") } if err := (PlanRequest{ Source: Endpoint{URL: "https://source.example/repo.git"}, Target: Endpoint{URL: "https://target.example/repo.git"}, Scope: RefScope{ Mappings: []RefMapping{ {Source: "main", Target: "stable"}, {Source: "release", Target: "stable"}, }, }, }).Validate(); err == nil { t.Fatalf("expected duplicate mapping validation error") } }

func TestClientReturnsAuthProviderErrors(t *testing.T) { _, err := New(Options{Auth: errAuthProvider{}}).buildProbeConfig(context.Background(), ProbeRequest{ Source: Endpoint{URL: "https://source.example/repo.git"}, }) if err == nil { t.Fatalf("expected auth provider error") } }

func TestClientSyncEndToEndWithLocalRepos(t *testing.T) { sourceRepo, sourceFS := syncertest.NewMemoryRepo(t) syncertest.MakeCommits(t, sourceRepo, sourceFS, 1) targetRepo, _ := syncertest.NewMemoryRepo(t)

sourceServer := newSmartHTTPRepoServer(t, sourceRepo) targetServer := newSmartHTTPRepoServer(t, targetRepo) defer sourceServer.Close() defer targetServer.Close()

client := New(Options{}) result, err := client.Sync(context.Background(), SyncRequest{ Source: Endpoint{URL: sourceServer.RepoURL()}, Target: Endpoint{URL: targetServer.RepoURL()}, Scope: RefScope{Branches: []string{"master"}}, Policy: SyncPolicy{Protocol: ProtocolV1}, }) if err != nil { t.Fatalf("client sync: %v", err) } if len(result.Refs) != 1 || result.Refs[0].Action != ActionCreate { t.Fatalf("unexpected ref results: %+v", result.Refs) } if result.Counts.Applied != 1 { t.Fatalf("applied = %d, want 1", result.Counts.Applied) }

targetRef, err := targetRepo.Reference(plumbing.NewBranchReferenceName("master"), true) if err != nil { t.Fatalf("resolve target ref: %v", err) } sourceRef, err := sourceRepo.Reference(plumbing.NewBranchReferenceName("master"), true) if err != nil { t.Fatalf("resolve source ref: %v", err) } if targetRef.Hash() != sourceRef.Hash() { t.Fatalf("target hash = %s, want %s", targetRef.Hash(), sourceRef.Hash()) } }

type smartHTTPRepoServer struct { tb testing.TB repo *git.Repository repoPath string server *httptest.Server }

func newSmartHTTPRepoServer(tb testing.TB, repo *git.Repository) *smartHTTPRepoServer { tb.Helper()

s := &smartHTTPRepoServer{ tb: tb, repo: repo, repoPath: "/repo.git", } s.server = httptest.NewServer(http.HandlerFunc(s.handle)) return s }

func (s *smartHTTPRepoServer) Close() { s.server.Close() }

func (s *smartHTTPRepoServer) RepoURL() string { return s.server.URL + s.repoPath }

func (s *smartHTTPRepoServer) handle(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == s.repoPath+"/info/refs": s.handleInfoRefs(w, r) case r.Method == http.MethodPost && r.URL.Path == s.repoPath+"/git-upload-pack": s.handleUploadPack(w, r) case r.Method == http.MethodPost && r.URL.Path == s.repoPath+"/git-receive-pack": s.handleReceivePack(w, r) default: http.NotFound(w, r) } }

func (s *smartHTTPRepoServer) handleInfoRefs(w http.ResponseWriter, r *http.Request) { service := r.URL.Query().Get("service") if service != "git-upload-pack" && service != "git-receive-pack" { http.Error(w, "missing service", http.StatusBadRequest) return }

var buf bytes.Buffer if err := transport.AdvertiseReferences(r.Context(), s.repo.Storer, &buf, transport.Service(service), false); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }

w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) if _, err := w.Write(buf.Bytes()); err != nil { s.tb.Fatalf("write advertised refs: %v", err) } }

func (s *smartHTTPRepoServer) handleUploadPack(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer r.Body.Close()

var buf bytes.Buffer reader := io.NopCloser(bytes.NewReader(body)) writer := nopWriteCloser{&buf} if err := transport.UploadPack(r.Context(), s.repo.Storer, reader, writer, &transport.UploadPackOptions{ StatelessRPC: true, }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }

w.Header().Set("Content-Type", "application/x-git-upload-pack-result") if _, err := w.Write(buf.Bytes()); err != nil { s.tb.Fatalf("write upload-pack response: %v", err) } }

func (s *smartHTTPRepoServer) handleReceivePack(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer r.Body.Close()

if !bytes.Contains(body, []byte("PACK")) { req := packp.NewUpdateRequests() if err := req.Decode(bytes.NewReader(body)); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }

report := packp.NewReportStatus() report.UnpackStatus = "ok" for _, cmd := range req.Commands { status := "ok" if cmd.New.IsZero() { if err := s.repo.Storer.RemoveReference(cmd.Name); err != nil { status = err.Error() } } else { if err := s.repo.Storer.SetReference(plumbing.NewHashReference(cmd.Name, cmd.New)); err != nil { status = err.Error() } } report.CommandStatuses = append(report.CommandStatuses, &packp.CommandStatus{ ReferenceName: cmd.Name, Status: status, }) } s.writeReceivePackReport(w, report) return }

var buf bytes.Buffer reader := io.NopCloser(bytes.NewReader(body)) writer := nopWriteCloser{&buf} if err := transport.ReceivePack(r.Context(), s.repo.Storer, reader, writer, &transport.ReceivePackOptions{ StatelessRPC: true, }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }

w.Header().Set("Content-Type", "application/x-git-receive-pack-result") if _, err := w.Write(buf.Bytes()); err != nil { s.tb.Fatalf("write receive-pack response: %v", err) } }

func (s *smartHTTPRepoServer) writeReceivePackReport(w http.ResponseWriter, report *packp.ReportStatus) { var buf bytes.Buffer if err := report.Encode(nopWriteCloser{&buf}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }

w.Header().Set("Content-Type", "application/x-git-receive-pack-result") if _, err := w.Write(buf.Bytes()); err != nil { s.tb.Fatalf("write receive-pack report: %v", err) } }

type nopWriteCloser struct{ io.Writer }

func (nopWriteCloser) Close() error { return nil }


Apkg/gitsync/client\_test.go+263

1 2 3 4 5 6 7 8 9 10 11 12 13 14

// Package gitsync provides the public orchestration API for git-sync. // // The public surface is intentionally narrower than the internal engine: // callers express sync intent through typed probe, plan, and sync requests, // while relay selection, batching, and fallback strategy remain internal. // // The package is designed for embedders such as queue workers. Callers can: // - inject an HTTP client for transport, OTEL, proxy, TLS, and timeout control // - inject an auth provider that resolves source and target credentials // - inspect structured results for per-ref outcomes and aggregate counters // // Current advanced engine tuning such as batch sizing, max pack thresholds, and // heap measurement remains outside this stable public surface. package gitsync


Apkg/gitsync/doc.go+14

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

package gitsync_test

import ( "context" "net/http"

"github.com/soph/git-sync/pkg/gitsync" )

func ExampleClient_Sync() { client := gitsync.New(gitsync.Options{ HTTPClient: &http.Client{}, Auth: gitsync.StaticAuthProvider{ Source: gitsync.EndpointAuth{Token: "source-token"}, Target: gitsync.EndpointAuth{Token: "target-token"}, }, })

_, _ = client.Sync(context.Background(), gitsync.SyncRequest{ Source: gitsync.Endpoint{URL: "https://github.example/source/repo.git"}, Target: gitsync.Endpoint{URL: "https://git.example/target/repo.git"}, Scope: gitsync.RefScope{Branches: []string{"main"}}, Policy: gitsync.SyncPolicy{ IncludeTags: true, Protocol: gitsync.ProtocolAuto, }, })

// Output: }


Apkg/gitsync/example\_test.go+30

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

package internalbridge

import ( "context" "net/http"

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

type ProtocolMode string

type Config struct { raw syncer.Config }

const ProtocolAuto ProtocolMode = validation.ProtocolAuto const ProtocolV1 ProtocolMode = validation.ProtocolV1 const ProtocolV2 ProtocolMode = validation.ProtocolV2

type RefMapping struct { Source string Target string }

type Endpoint struct { URL string }

type EndpointAuth struct { Username string Token string BearerToken string SkipTLSVerify bool }

type RefScope struct { Branches []string Mappings []RefMapping }

type SyncPolicy struct { IncludeTags bool Force bool Prune bool Protocol ProtocolMode }

func ProbeConfig(source Endpoint, sourceAuth EndpointAuth, target *Endpoint, targetAuth EndpointAuth, protocol ProtocolMode, includeTags, collectStats bool, httpClient *http.Client) Config { cfg := syncer.Config{ Source: ToSyncerEndpoint(source, sourceAuth), HTTPClient: httpClient, IncludeTags: includeTags, ShowStats: collectStats, ProtocolMode: protocolString(protocol), } if target != nil { cfg.Target = ToSyncerEndpoint(*target, targetAuth) } return Config{raw: cfg} }

func SyncConfig(source Endpoint, sourceAuth EndpointAuth, target Endpoint, targetAuth EndpointAuth, scope RefScope, policy SyncPolicy, collectStats, dryRun bool, httpClient *http.Client) Config { return Config{raw: syncer.Config{ Source: ToSyncerEndpoint(source, sourceAuth), Target: ToSyncerEndpoint(target, targetAuth), HTTPClient: httpClient, Branches: append([]string(nil), scope.Branches...), Mappings: ToValidationMappings(scope.Mappings), IncludeTags: policy.IncludeTags, DryRun: dryRun, ShowStats: collectStats, Force: policy.Force, Prune: policy.Prune, ProtocolMode: protocolString(policy.Protocol), MaterializedMaxObjects: syncer.DefaultMaterializedMaxObjects, }} }

func Probe(ctx context.Context, cfg Config) (syncer.ProbeResult, error) { return syncer.Probe(ctx, cfg.raw) }

func Run(ctx context.Context, cfg Config) (syncer.Result, error) { return syncer.Run(ctx, cfg.raw) }

func ToSyncerEndpoint(endpoint Endpoint, auth EndpointAuth) syncer.Endpoint { return syncer.Endpoint{ URL: endpoint.URL, Username: auth.Username, Token: auth.Token, BearerToken: auth.BearerToken, SkipTLSVerify: auth.SkipTLSVerify, } }

func protocolString(mode ProtocolMode) string { if mode == "" { return string(ProtocolAuto) } return string(mode) }

func ToValidationMappings(mappings []RefMapping) []validation.RefMapping { out := make([]validation.RefMapping, 0, len(mappings)) for _, mapping := range mappings { out = append(out, validation.RefMapping{ Source: mapping.Source, Target: mapping.Target, }) } return out }


Apkg/gitsync/internalbridge/config.go+114

package internalbridge

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

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

type RefKind string

const (
    RefKindBranch RefKind = RefKind(planner.RefKindBranch)
    RefKindTag    RefKind = RefKind(planner.RefKindTag)
)

type Action string

const (
    ActionCreate Action = Action(planner.ActionCreate)
    ActionUpdate Action = Action(planner.ActionUpdate)
    ActionDelete Action = Action(planner.ActionDelete)
    ActionSkip   Action = Action(planner.ActionSkip)
    ActionBlock  Action = Action(planner.ActionBlock)
)

type RefResult struct {
    Branch     string  `json:"branch"`
    SourceRef  string  `json:"source_ref"`
    TargetRef  string  `json:"target_ref"`
    SourceHash string  `json:"source_hash"`
    TargetHash string  `json:"target_hash"`
    Kind       RefKind `json:"kind"`
    Action     Action  `json:"action"`
    Reason     string  `json:"reason"`
}

type RefPlan = RefResult

type RefInfo struct {
    Name string `json:"name"`
    Hash string `json:"hash"`
}

type ServiceStats struct {
    Name          string `json:"name"`
    Requests      int    `json:"requests"`
    RequestBytes  int64  `json:"request_bytes"`
    ResponseBytes int64  `json:"response_bytes"`
    Wants         int    `json:"wants"`
    Haves         int    `json:"haves"`
    Commands      int    `json:"commands"`
}

type Stats struct {
    Enabled bool                     `json:"enabled"`
    Items   map[string]*ServiceStats `json:"items"`
}

type Measurement struct {
    Enabled            bool   `json:"enabled"`
    ElapsedMillis      int64  `json:"elapsed_millis"`
    PeakAllocBytes     uint64 `json:"peak_alloc_bytes"`
    PeakHeapInuseBytes uint64 `json:"peak_heap_inuse_bytes"`
    TotalAllocBytes    uint64 `json:"total_alloc_bytes"`
    GCCount            uint32 `json:"gc_count"`
}

type ProbeResult struct {
    SourceURL     string      `json:"source_url"`
    TargetURL     string      `json:"target_url,omitempty"`
    RequestedMode string      `json:"requested_mode"`
    Protocol      string      `json:"protocol"`
    RefPrefixes   []string    `json:"ref_prefixes"`
    Capabilities  []string    `json:"source_capabilities"`
    TargetCaps    []string    `json:"target_capabilities,omitempty"`
    Refs          []RefInfo   `json:"refs"`
    Stats         Stats       `json:"stats"`
    Measurement   Measurement `json:"measurement"`
}

type SyncCounts struct {
    Applied int `json:"applied"`
    Skipped int `json:"skipped"`
    Blocked int `json:"blocked"`
    Deleted int `json:"deleted"`
}

type BatchSummary struct {
    Enabled bool `json:"enabled"`
    Planned int  `json:"planned"`
    Done    int  `json:"done"`
}

type ExecutionSummary struct {
    DryRun             bool         `json:"dry_run"`
    Protocol           string       `json:"protocol"`
    Relay              bool         `json:"relay"`
    Mode               string       `json:"mode"`
    Reason             string       `json:"reason"`
    BootstrapSuggested bool         `json:"bootstrap_suggested"`
    Batch              BatchSummary `json:"batch"`
}

type SyncResult struct {
    Refs        []RefResult      `json:"refs"`
    Counts      SyncCounts       `json:"counts"`
    Execution   ExecutionSummary `json:"execution"`
    Stats       Stats            `json:"stats"`
    Measurement Measurement      `json:"measurement"`
}

type PlanResult = SyncResult

func FromProbeResult(result syncer.ProbeResult) ProbeResult {
    out := ProbeResult{
        SourceURL:     result.SourceURL,
        TargetURL:     result.TargetURL,
        RequestedMode: result.RequestedMode,
        Protocol:      result.Protocol,
        RefPrefixes:   append([]string(nil), result.RefPrefixes...),
        Capabilities:  append([]string(nil), result.Capabilities...),
        TargetCaps:    append([]string(nil), result.TargetCaps...),
        Refs:          make([]RefInfo, 0, len(result.Refs)),
        Stats:         FromStats(result.Stats),
        Measurement:   FromMeasurement(result.Measurement),
    }
    for _, ref := range result.Refs {
        out.Refs = append(out.Refs, RefInfo{Name: ref.Name, Hash: ref.Hash.String()})
    }
    return out
}

func FromSyncResult(result syncer.Result) SyncResult {
    out := SyncResult{
        Refs: make([]RefResult, 0, len(result.Plans)),
        Counts: SyncCounts{
            Applied: result.Pushed,
            Skipped: result.Skipped,
            Blocked: result.Blocked,
            Deleted: result.Deleted,
        },
        Execution: ExecutionSummary{
            DryRun:             result.DryRun,
            Protocol:           result.Protocol,
            Relay:              result.Relay,
            Mode:               result.RelayMode,
            Reason:             result.RelayReason,
            BootstrapSuggested: result.BootstrapSuggested,
            Batch: BatchSummary{
                Enabled: result.Batching,
                Planned: result.PlannedBatchCount,
                Done:    result.BatchCount,
            },
        },
        Stats:       FromStats(result.Stats),
        Measurement: FromMeasurement(result.Measurement),
    }
    for _, plan := range result.Plans {
        out.Refs = append(out.Refs, RefResult{
            Branch:     plan.Branch,
            SourceRef:  plan.SourceRef.String(),
            TargetRef:  plan.TargetRef.String(),
            SourceHash: HashString(plan.SourceHash),
            TargetHash: HashString(plan.TargetHash),
            Kind:       RefKind(plan.Kind),
            Action:     Action(plan.Action),
            Reason:     plan.Reason,
        })
    }
    return out
}

func FromStats(stats syncer.Stats) Stats {
    out := Stats{Enabled: stats.Enabled, Items: make(map[string]*ServiceStats, len(stats.Items))}
    for key, item := range stats.Items {
        copyItem := *item
        out.Items[key] = &ServiceStats{
            Name:          copyItem.Name,
            Requests:      copyItem.Requests,
            RequestBytes:  copyItem.RequestBytes,
            ResponseBytes: copyItem.ResponseBytes,
            Wants:         copyItem.Wants,
            Haves:         copyItem.Haves,
            Commands:      copyItem.Commands,
        }
    }
    return out
}

func FromMeasurement(m syncer.Measurement) Measurement {
    return Measurement{
        Enabled:            m.Enabled,
        ElapsedMillis:      m.ElapsedMillis,
        PeakAllocBytes:     m.PeakAllocBytes,
        PeakHeapInuseBytes: m.PeakHeapInuseBytes,
        TotalAllocBytes:    m.TotalAllocBytes,
        GCCount:            m.GCCount,
    }
}

func HashString(hash plumbing.Hash) string {
    if hash.IsZero() {
        return ""
    }
    return hash.String()
}

Apkg/gitsync/internalbridge/model.go+207

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

package internalbridge

import (
    "testing"

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

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

func TestHashStringZeroHashIsEmpty(t *testing.T) {
    got := HashString(plumbing.ZeroHash)
    if got != "" {
        t.Fatalf("HashString(zero) = %q, want empty string", got)
    }
}

func TestFromProbeResultCopiesStableFields(t *testing.T) {
    got := FromProbeResult(syncer.ProbeResult{
        SourceURL:     "https://source.example/repo.git",
        TargetURL:     "https://target.example/repo.git",
        RequestedMode: "auto",
        Protocol:      "v2",
        RefPrefixes:   []string{"refs/heads/"},
        Capabilities:  []string{"ls-refs", "fetch"},
        TargetCaps:    []string{"report-status"},
        Refs: []syncer.RefInfo{
            {Name: "refs/heads/main", Hash: plumbing.NewHash("1111111111111111111111111111111111111111")},
        },
        Stats: syncer.Stats{
            Enabled: true,
            Items: map[string]*syncer.ServiceStats{
                "source": {Name: "source", Requests: 2, Wants: 3},
            },
        },
        Measurement: syncer.Measurement{Enabled: true, ElapsedMillis: 42},
    })

if got.SourceURL != "https://source.example/repo.git" || got.TargetURL != "https://target.example/repo.git" {
        t.Fatalf("unexpected URLs: %+v", got)
    }
    if len(got.Refs) != 1 || got.Refs[0].Hash != "1111111111111111111111111111111111111111" {
        t.Fatalf("unexpected refs: %+v", got.Refs)
    }
    if !got.Stats.Enabled || got.Stats.Items["source"].Requests != 2 || got.Measurement.ElapsedMillis != 42 {
        t.Fatalf("unexpected stats/measurement: %+v %+v", got.Stats, got.Measurement)
    }
}

func TestFromSyncResultShapesStableSummary(t *testing.T) {
    got := FromSyncResult(syncer.Result{
        Plans: []planner.BranchPlan{
            {
                Branch:     "main",
                SourceRef:  plumbing.ReferenceName("refs/heads/main"),
                TargetRef:  plumbing.ReferenceName("refs/heads/main"),
                SourceHash: plumbing.NewHash("1111111111111111111111111111111111111111"),
                TargetHash: plumbing.NewHash("2222222222222222222222222222222222222222"),
                Kind:       planner.RefKindBranch,
                Action:     planner.ActionUpdate,
                Reason:     "fast-forward",
            },
        },
        Pushed:             1,
        Skipped:            2,
        Blocked:            3,
        Deleted:            4,
        DryRun:             true,
        Relay:              true,
        RelayMode:          "incremental-relay",
        RelayReason:        "fast-forward",
        Batching:           true,
        BatchCount:         5,
        PlannedBatchCount:  6,
        TempRefs:           []string{"refs/gitsync/bootstrap/heads/main/1"},
        BootstrapSuggested: true,
        Protocol:           "v2",
    })

if len(got.Refs) != 1 || got.Refs[0].Branch != "main" {
        t.Fatalf("unexpected refs: %+v", got.Refs)
    }
    if got.Counts.Applied != 1 || got.Counts.Skipped != 2 || got.Counts.Blocked != 3 || got.Counts.Deleted != 4 {
        t.Fatalf("unexpected counts: %+v", got.Counts)
    }
    if !got.Execution.DryRun || !got.Execution.Relay || got.Execution.Mode != "incremental-relay" || got.Execution.Reason != "fast-forward" {
        t.Fatalf("unexpected execution summary: %+v", got.Execution)
    }
    if !got.Execution.Batch.Enabled || got.Execution.Batch.Done != 5 || got.Execution.Batch.Planned != 6 {
        t.Fatalf("unexpected batch summary: %+v", got.Execution.Batch)
    }
    if !got.Execution.BootstrapSuggested {
        t.Fatalf("expected bootstrap suggestion in execution summary")
    }
}

Apkg/gitsync/internalbridge/model_test.go+96

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

package gitsync

import (
    "context"
    "github.com/soph/git-sync/pkg/gitsync/internalbridge"
)

// ProtocolMode controls source-side protocol negotiation.
type ProtocolMode string

const (
    ProtocolAuto ProtocolMode = "auto"
    ProtocolV1   ProtocolMode = "v1"
    ProtocolV2   ProtocolMode = "v2"
)

// Endpoint identifies a remote Git endpoint.
type Endpoint struct {
    URL string
}

// EndpointAuth carries explicit per-request auth and TLS settings.
// It is resolved through an AuthProvider rather than embedded in Endpoint so
// endpoint identity does not also become the public auth-precedence boundary.
type EndpointAuth struct {
    Username      string
    Token         string
    BearerToken   string
    SkipTLSVerify bool
}

// EndpointRole identifies whether auth is being resolved for the source or target.
type EndpointRole string

const (
    SourceRole EndpointRole = "source"
    TargetRole EndpointRole = "target"
)

// AuthProvider resolves auth for a request endpoint.
type AuthProvider interface {
    AuthFor(ctx context.Context, endpoint Endpoint, role EndpointRole) (EndpointAuth, error)
}

// StaticAuthProvider returns fixed source and target auth values.
type StaticAuthProvider struct {
    Source EndpointAuth
    Target EndpointAuth
}

// AuthFor implements AuthProvider.
func (p StaticAuthProvider) AuthFor(_ context.Context, _ Endpoint, role EndpointRole) (EndpointAuth, error) {
    if role == TargetRole {
        return p.Target, nil
    }
    return p.Source, nil
}

// RefMapping is an explicit source-to-target ref mapping.
type RefMapping struct {
    Source string
    Target string
}

// RefScope constrains which refs a request manages.
type RefScope struct {
    Branches []string
    Mappings []RefMapping
}

// SyncPolicy controls high-level sync behavior.
type SyncPolicy struct {
    IncludeTags bool
    Force       bool
    Prune       bool
    Protocol    ProtocolMode
}

// ProbeRequest inspects source refs and optional target capabilities.
type ProbeRequest struct {
    Source       Endpoint
    Target       *Endpoint
    IncludeTags  bool
    Protocol     ProtocolMode
    CollectStats bool
}

// PlanRequest computes ref actions without pushing.
type PlanRequest struct {
    Source       Endpoint
    Target       Endpoint
    Scope        RefScope
    Policy       SyncPolicy
    CollectStats bool
}

// SyncRequest executes a sync between two remotes.
type SyncRequest struct {
    Source       Endpoint
    Target       Endpoint
    Scope        RefScope
    Policy       SyncPolicy
    CollectStats bool
}

type RefKind = internalbridge.RefKind

const (
    RefKindBranch RefKind = internalbridge.RefKindBranch
    RefKindTag    RefKind = internalbridge.RefKindTag
)

type Action = internalbridge.Action

const (
    ActionCreate Action = internalbridge.ActionCreate
    ActionUpdate Action = internalbridge.ActionUpdate
    ActionDelete Action = internalbridge.ActionDelete
    ActionSkip   Action = internalbridge.ActionSkip
    ActionBlock  Action = internalbridge.ActionBlock
)

type RefResult = internalbridge.RefResult
type RefPlan = internalbridge.RefPlan
type RefInfo = internalbridge.RefInfo
type ServiceStats = internalbridge.ServiceStats
type Stats = internalbridge.Stats
type Measurement = internalbridge.Measurement
type ProbeResult = internalbridge.ProbeResult
type SyncCounts = internalbridge.SyncCounts
type BatchSummary = internalbridge.BatchSummary
type ExecutionSummary = internalbridge.ExecutionSummary
type SyncResult = internalbridge.SyncResult
type PlanResult = internalbridge.PlanResult

Apkg/gitsync/types.go+134

package unstable

import ( "context" "net/http"

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

"github.com/soph/git-sync/internal/syncer" "github.com/soph/git-sync/internal/validation" "github.com/soph/git-sync/pkg/gitsync" "github.com/soph/git-sync/pkg/gitsync/internalbridge" )

const DefaultMaterializedMaxObjects = syncer.DefaultMaterializedMaxObjects

type ( Result = syncer.Result ProbeResult = syncer.ProbeResult FetchResult = syncer.FetchResult RefInfo = syncer.RefInfo Stats = syncer.Stats Measurement = syncer.Measurement )

type Options struct { HTTPClient *http.Client Auth gitsync.AuthProvider }

type Client struct { httpClient *http.Client auth gitsync.AuthProvider }

type AdvancedOptions struct { CollectStats bool MeasureMemory bool Verbose bool MaxPackBytes int64 BatchMaxPackBytes int64 MaterializedMaxObjects int }

type ProbeRequest struct { Source gitsync.Endpoint Target *gitsync.Endpoint IncludeTags bool Protocol gitsync.ProtocolMode Options AdvancedOptions }

type SyncRequest struct { Source gitsync.Endpoint Target gitsync.Endpoint Scope gitsync.RefScope Policy gitsync.SyncPolicy DryRun bool Options AdvancedOptions }

type BootstrapRequest struct { Source gitsync.Endpoint Target gitsync.Endpoint Scope gitsync.RefScope IncludeTags bool Protocol gitsync.ProtocolMode Options AdvancedOptions }

type FetchRequest struct { Source gitsync.Endpoint Scope gitsync.RefScope IncludeTags bool Protocol gitsync.ProtocolMode HaveRefs []string HaveHashes []plumbing.Hash Options AdvancedOptions }

func New(opts Options) *Client { return &Client{httpClient: opts.HTTPClient, auth: opts.Auth} }

func (c *Client) Probe(ctx context.Context, req ProbeRequest) (ProbeResult, error) { cfg, err := c.buildProbeConfig(ctx, req) if err != nil { return ProbeResult{}, err } return syncer.Probe(ctx, cfg) }

func (c *Client) Plan(ctx context.Context, req SyncRequest) (Result, error) { planReq := req planReq.DryRun = true cfg, err := c.buildSyncConfig(ctx, planReq) if err != nil { return Result{}, err } return syncer.Run(ctx, cfg) }

func (c *Client) Sync(ctx context.Context, req SyncRequest) (Result, error) { cfg, err := c.buildSyncConfig(ctx, req) if err != nil { return Result{}, err } return syncer.Run(ctx, cfg) }

func (c *Client) Bootstrap(ctx context.Context, req BootstrapRequest) (Result, error) { cfg, err := c.buildBootstrapConfig(ctx, req) if err != nil { return Result{}, err } return syncer.Bootstrap(ctx, cfg) }

func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResult, error) { cfg, err := c.buildFetchConfig(ctx, req) if err != nil { return FetchResult{}, err } return syncer.Fetch(ctx, cfg, append([]string(nil), req.HaveRefs...), append([]plumbing.Hash(nil), req.HaveHashes...)) }

func (c *Client) buildProbeConfig(ctx context.Context, req ProbeRequest) (syncer.Config, error) { source, err := c.resolveEndpoint(ctx, req.Source, gitsync.SourceRole) if err != nil { return syncer.Config{}, err } cfg := syncer.Config{ Source: source, HTTPClient: c.httpClient, IncludeTags: req.IncludeTags, ShowStats: req.Options.CollectStats, MeasureMemory: req.Options.MeasureMemory, ProtocolMode: protocolString(req.Protocol), Verbose: req.Options.Verbose, } if req.Target != nil { target, err := c.resolveEndpoint(ctx, *req.Target, gitsync.TargetRole) if err != nil { return syncer.Config{}, err } cfg.Target = target } return cfg, nil }

func (c *Client) buildSyncConfig(ctx context.Context, req SyncRequest) (syncer.Config, error) { source, err := c.resolveEndpoint(ctx, req.Source, gitsync.SourceRole) if err != nil { return syncer.Config{}, err } target, err := c.resolveEndpoint(ctx, req.Target, gitsync.TargetRole) if err != nil { return syncer.Config{}, err } maxObjects := req.Options.MaterializedMaxObjects if maxObjects == 0 { maxObjects = DefaultMaterializedMaxObjects } return syncer.Config{ Source: source, Target: target, HTTPClient: c.httpClient, Branches: append([]string(nil), req.Scope.Branches...), Mappings: validationMappings(req.Scope.Mappings), IncludeTags: req.Policy.IncludeTags, DryRun: req.DryRun, ShowStats: req.Options.CollectStats, MeasureMemory: req.Options.MeasureMemory, Force: req.Policy.Force, Prune: req.Policy.Prune, MaterializedMaxObjects: maxObjects, ProtocolMode: protocolString(req.Policy.Protocol), Verbose: req.Options.Verbose, }, nil }

func (c *Client) buildBootstrapConfig(ctx context.Context, req BootstrapRequest) (syncer.Config, error) { source, err := c.resolveEndpoint(ctx, req.Source, gitsync.SourceRole) if err != nil { return syncer.Config{}, err } target, err := c.resolveEndpoint(ctx, req.Target, gitsync.TargetRole) if err != nil { return syncer.Config{}, err } return syncer.Config{ Source: source, Target: target, HTTPClient: c.httpClient, Branches: append([]string(nil), req.Scope.Branches...), Mappings: validationMappings(req.Scope.Mappings), IncludeTags: req.IncludeTags, ShowStats: req.Options.CollectStats, MeasureMemory: req.Options.MeasureMemory, MaxPackBytes: req.Options.MaxPackBytes, BatchMaxPackBytes: req.Options.BatchMaxPackBytes, ProtocolMode: protocolString(req.Protocol), Verbose: req.Options.Verbose, }, nil }

func (c *Client) buildFetchConfig(ctx context.Context, req FetchRequest) (syncer.Config, error) { source, err := c.resolveEndpoint(ctx, req.Source, gitsync.SourceRole) if err != nil { return syncer.Config{}, err } return syncer.Config{ Source: source, HTTPClient: c.httpClient, Branches: append([]string(nil), req.Scope.Branches...), IncludeTags: req.IncludeTags, ShowStats: req.Options.CollectStats, MeasureMemory: req.Options.MeasureMemory, ProtocolMode: protocolString(req.Protocol), Verbose: req.Options.Verbose, }, nil }

func (c *Client) authFor(ctx context.Context, endpoint gitsync.Endpoint, role gitsync.EndpointRole) (gitsync.EndpointAuth, error) { if c.auth == nil { return gitsync.EndpointAuth{}, nil } return c.auth.AuthFor(ctx, endpoint, role) }

func (c *Client) resolveEndpoint(ctx context.Context, endpoint gitsync.Endpoint, role gitsync.EndpointRole) (syncer.Endpoint, error) { auth, err := c.authFor(ctx, endpoint, role) if err != nil { return syncer.Endpoint{}, err } return syncerEndpoint(endpoint, auth), nil }

func protocolString(mode gitsync.ProtocolMode) string { if mode == "" { return string(gitsync.ProtocolAuto) } return string(mode) }

func syncerEndpoint(endpoint gitsync.Endpoint, auth gitsync.EndpointAuth) syncer.Endpoint { return internalbridge.ToSyncerEndpoint( internalbridge.Endpoint{URL: endpoint.URL}, internalbridge.EndpointAuth{ Username: auth.Username, Token: auth.Token, BearerToken: auth.BearerToken, SkipTLSVerify: auth.SkipTLSVerify, }, ) }

func validationMappings(mappings []gitsync.RefMapping) []validation.RefMapping { bridgeMappings := make([]internalbridge.RefMapping, 0, len(mappings)) for _, mapping := range mappings { bridgeMappings = append(bridgeMappings, internalbridge.RefMapping{ Source: mapping.Source, Target: mapping.Target, }) } return internalbridge.ToValidationMappings(bridgeMappings) }


Apkg/gitsync/unstable/client.go+267

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

package unstable

import ( "context" "net/http" "testing"

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

"github.com/soph/git-sync/pkg/gitsync" )

func TestBuildSyncConfigCarriesAdvancedOptions(t *testing.T) { cfg, err := New(Options{ HTTPClient: &http.Client{}, Auth: gitsync.StaticAuthProvider{ Source: gitsync.EndpointAuth{Token: "src"}, Target: gitsync.EndpointAuth{Token: "dst"}, }, }).buildSyncConfig(context.Background(), SyncRequest{ Source: gitsync.Endpoint{URL: "https://source.example/repo.git"}, Target: gitsync.Endpoint{URL: "https://target.example/repo.git"}, Scope: gitsync.RefScope{Branches: []string{"main"}}, Policy: gitsync.SyncPolicy{IncludeTags: true, Force: true, Prune: true}, DryRun: true, Options: AdvancedOptions{ CollectStats: true, MeasureMemory: true, Verbose: true, MaterializedMaxObjects: 123, }, }) if err != nil { t.Fatalf("buildSyncConfig: %v", err) } if !cfg.DryRun || !cfg.ShowStats || !cfg.MeasureMemory || !cfg.Verbose { t.Fatalf("advanced booleans not propagated: %+v", cfg) } if cfg.MaterializedMaxObjects != 123 { t.Fatalf("materialized max objects = %d, want 123", cfg.MaterializedMaxObjects) } if cfg.Source.Token != "src" || cfg.Target.Token != "dst" { t.Fatalf("auth not propagated: %+v %+v", cfg.Source, cfg.Target) } }

func TestBuildFetchConfigCopiesHaveHashesAtCallSite(t *testing.T) { req := FetchRequest{ Source: gitsync.Endpoint{URL: "https://source.example/repo.git"}, HaveHashes: []plumbing.Hash{plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, } cfg, err := New(Options{}).buildFetchConfig(context.Background(), req) if err != nil { t.Fatalf("buildFetchConfig: %v", err) } if cfg.Source.URL == "" { t.Fatalf("source URL not set") } }


Apkg/gitsync/unstable/client\_test.go+59

1 2 3 4 5 6 7 8 9

// Package unstable exposes advanced git-sync controls and commands that are // intentionally outside the stable pkg/gitsync surface. // // This package exists for first-party consumers such as the CLI and benchmark // tool that still need direct access to engine-adjacent controls like batch // sizing, heap measurement, verbose progress, and fetch/bootstrap entrypoints. // // The API in this package is explicitly not stable. package unstable


Apkg/gitsync/unstable/doc.go+9