Merge pull request #44 from entireio/soph/all-refs · Entire
Log in
Merge pull request #44 from entireio/soph/all-refs
940d11f→main·
Soph·2mo ago·27 files·+1,816 added/-193 removed
Add --all-refs for arbitrary refs/* mirroring + BestEffort failure mode
Changes
27
Mclient.go+9/-4
cmd/git-sync
Mbootstrap.go+2
Mfetch.go+2
Mflags.go+42
Mmain_test.go+349
Mprobe.go+2
Msyncplan.go+13
docs
Musage.md+59/-1
internal
gitproto
Mpush.go+40/-17
Mpush_test.go+5/-5
planner
Mplanner.go+75/-32
Mplanner_test.go+126/-6
Mrelay.go+12/-2
Mtypes.go+38/-7
strategy
bootstrap
Mbootstrap.go+45/-24
replicate
Mreplicate.go+3/-4
syncer
Mgit_http_backend_test.go+4/-4
Mintegration_test.go+644
Msyncer.go+110/-38
Msyncer_test.go+69
syncertest
Mrepo.go+41
validation
Mvalidation.go+19/-10
Mvalidation_test.go+30
internalbridge
Mconfig.go+16/-8
Mmodel.go+4
Mtypes.go+22/-9
unstable
Mclient.go+35/-22
102 unmodified lines
103
104
105
106
107
108
109
110
111
44 unmodified lines
156
157
158
157
159
160
161
162
12 unmodified lines
175
176
177
176
178
179
180
181
37 unmodified lines
219
220
221
220
221
222
223
224
225
226
227
228
3 unmodified lines
232
233
234
235
236
237
238
102 unmodified lines
targetAuth,
internalbridge.ProtocolMode(req.Protocol),
req.IncludeTags,
req.AllRefs,
req.CollectStats,
req.ExcludeRefPrefixes,
c.httpClient,
), nil
}
44 unmodified lines
if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
return fmt.Errorf("normalize protocol: %w", err)
}
if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings)); err != nil {
if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings), r.Scope.AllRefs); err != nil {
return fmt.Errorf("validate mappings: %w", err)
}
return nil
12 unmodified lines
if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
return fmt.Errorf("normalize protocol: %w", err)
}
if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings)); err != nil {
if _, err := validation.ValidateMappings(validationMappings(r.Scope.Mappings), r.Scope.AllRefs); err != nil {
return fmt.Errorf("validate mappings: %w", err)
}
return nil
37 unmodified lines
})
}
return internalbridge.RefScope{
Branches: append([]string(nil), scope.Branches...),
Mappings: mappings,
Branches: append([]string(nil), scope.Branches...),
Mappings: mappings,
AllRefs: scope.AllRefs,
ExcludeRefPrefixes: append([]string(nil), scope.ExcludeRefPrefixes...),
}
}
3 unmodified lines
IncludeTags: policy.IncludeTags,
Force: policy.Force,
Prune: policy.Prune,
BestEffort: policy.BestEffort,
Protocol: internalbridge.ProtocolMode(policy.Protocol),
}
}
Mclient.go+9/-4
73 unmodified lines
74
75
76
77
78
79
80
81
73 unmodified lines
cmd.Flags().StringVar(&branches, "branch", "", "comma-separated branch list; default is all source branches")
cmd.Flags().StringArrayVar(&mappings, "map", nil, "ref mapping in src:dst form; short names map branches, full refs map exact refs")
cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "mirror tags")
allRefsFlag(cmd, allRefsUsageBestEffort, &req.Scope.AllRefs, &req.BestEffort)
excludeRefPrefixFlag(cmd, &req.Scope.ExcludeRefPrefixes)
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
cmd.Flags().BoolVar(&req.Options.Progress, "progress", false, "show live per-side throughput on stderr (TTY only)")
Mcmd/git-sync/bootstrap.go+2
68 unmodified lines
69
70
71
72
73
74
75
76
68 unmodified lines
cmd.Flags().StringVar(&branches, "branch", "", "comma-separated branch list; default is all source branches")
cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "include tags in the fetch request")
cmd.Flags().BoolVar(&req.Scope.AllRefs, "all-refs", false, "include every refs/* on the source (branches, tags, notes, pulls, custom namespaces) in the fetch request")
excludeRefPrefixFlag(cmd, &req.Scope.ExcludeRefPrefixes)
addProtocolFlag(cmd, &protocolVal)
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
Mcmd/git-sync/fetch.go+2
45 unmodified lines
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
45 unmodified lines
cmd.Flags().Var(mode, "protocol", "protocol mode: auto, v1, or v2")
}
const (
allRefsUsageBestEffort = "mirror every refs/* on the source (branches, tags, notes, pulls, custom namespaces) on a best-effort basis; per-ref server rejections become warnings rather than failing the sync"
allRefsUsageStrict = "mirror every refs/* on the source (branches, tags, notes, pulls, custom namespaces); per-ref rejections fail the run, since replicate's contract is target == source"
allRefsUsageScopeOnly = "include every refs/* on the source (notes, pulls, custom namespaces) — scope only, no failure-handling effect"
)
// excludeRefPrefixFlag registers --exclude-ref-prefix. Repeatable; each
// prefix is matched as a string prefix against ref names (e.g.
// "refs/pull/" trims GitHub PR refs under --all-refs).
func excludeRefPrefixFlag(cmd *cobra.Command, prefixes *[]string) {
cmd.Flags().StringArrayVar(prefixes, "exclude-ref-prefix", nil,
"exclude refs whose names start with this prefix; repeatable. "+
"Subtracts from auto-discovery (branches/tags/--all-refs); explicit --map values are not subject to this filter")
}
// allRefsFlag registers --all-refs with the supplied usage string and
// bundles its implications. Each pointer in implies is set to true when
// --all-refs is set, via a PreRunE hook that fires after flag parsing.
//
// Not idempotent: calling twice on the same command stacks two PreRunE
// hooks on the same flag pointer. Call once per command.
func allRefsFlag(cmd *cobra.Command, usage string, allRefs *bool, implies ...*bool) {
cmd.Flags().BoolVar(allRefs, "all-refs", false, usage)
if len(implies) == 0 {
return
}
prev := cmd.PreRunE
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
if *allRefs {
for _, p := range implies {
if p != nil {
*p = true
}
}
}
if prev != nil {
return prev(cmd, args)
}
return nil
}
}
func newProtocolFlag() protocolModeFlag {
return protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
}
Mcmd/git-sync/flags.go+42
13 unmodified lines
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
226 unmodified lines
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
133 unmodified lines
693
694
695
696
697
698
699
700
701
702
703
125 unmodified lines
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
13 unmodified lines
"testing"
"time"
"entire.io/entire/git-sync/internal/syncertest"
"entire.io/entire/git-sync/unstable"
billy "github.com/go-git/go-billy/v6"
"github.com/go-git/go-billy/v6/memfs"
git "github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/format/pktline"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/plumbing/protocol/packp"
"github.com/go-git/go-git/v6/plumbing/protocol/packp/capability"
"github.com/go-git/go-git/v6/plumbing/protocol/packp/sideband"
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/go-git/go-git/v6/storage/memory"
)
226 unmodified lines
}
}
// Smoke test: cobra flag parsing through the full sync pipeline.
func TestRun_Sync_AllRefsSmokeTest(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
notesRef := plumbing.ReferenceName("refs/notes/commits")
head := syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
targetServer := newSmartHTTPRepoServer(t, targetRepo)
defer sourceServer.Close()
defer targetServer.Close()
output, err := captureStdout(func() error {
return run(context.Background(), []string{
"sync",
"--all-refs",
"--json",
sourceServer.RepoURL(),
targetServer.RepoURL(),
})
})
if err != nil {
t.Fatalf("run sync --all-refs: %v\noutput=%s", err, output)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode sync json: %v\noutput=%s", err, output)
}
plans, ok := result["plans"].([]any)
if !ok || len(plans) < 2 {
t.Fatalf("expected at least 2 plans (branch + notes), got %#v", result["plans"])
}
var foundNotesRef bool
for _, raw := range plans {
plan, ok := raw.(map[string]any)
if !ok {
continue
}
if plan["targetRef"] == "refs/notes/commits" {
if plan["kind"] != "other" {
t.Errorf("expected notes ref kind=other, got %#v", plan["kind"])
}
if plan["action"] != "create" {
t.Errorf("expected notes ref action=create, got %#v", plan["action"])
}
foundNotesRef = true
}
}
if !foundNotesRef {
t.Fatalf("refs/notes/commits not in plans output: %s", output)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target: %v", err)
}
if gotNotes.Hash() != head {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), head)
}
}
// probe --exclude-ref-prefix must filter the returned ref list — otherwise
// the CLI knob is wired but ineffective for previewing scoped state.
func TestRun_Probe_ExcludeRefPrefixFiltersReturnedRefs(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
notesRef := plumbing.ReferenceName("refs/notes/commits")
syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
pullRef := plumbing.ReferenceName("refs/pull/1/head")
syncertest.SetRefAtBranch(t, sourceRepo, pullRef, testBranch)
sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
defer sourceServer.Close()
output, err := captureStdout(func() error {
return run(context.Background(), []string{
"probe",
"--all-refs",
"--exclude-ref-prefix", "refs/pull/",
"--json",
sourceServer.RepoURL(),
})
})
if err != nil {
t.Fatalf("run probe: %v\noutput=%s", err, output)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode probe json: %v\noutput=%s", err, output)
}
refs, ok := result["refs"].([]any)
if !ok {
t.Fatalf("expected refs array, got %#v", result["refs"])
}
var sawNotes bool
for _, raw := range refs {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
name, ok := entry["name"].(string)
if !ok {
continue
}
if name == string(pullRef) {
t.Fatalf("expected %s excluded from probe output, but it appeared", pullRef)
}
if name == string(notesRef) {
sawNotes = true
}
}
if !sawNotes {
t.Fatalf("expected %s in probe output", notesRef)
}
}
// CLI smoke test for --exclude-ref-prefix under --all-refs: refs/pull/* on
// the source is trimmed, refs/notes/commits is kept.
func TestRun_Sync_ExcludeRefPrefixTrimsPullRefs(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
err = run(context.Background(), []string{
"sync",
"--all-refs",
"--exclude-ref-prefix", "refs/pull/",
"--json",
sourceServer.RepoURL(),
targetServer.RepoURL(),
})
if err != nil {
t.Fatalf("run sync --all-refs --exclude-ref-prefix: %v", err)
}
if _, err := targetRepo.Reference(notesRef, true); err != nil {
t.Errorf("expected refs/notes/commits on target, got err=%v", err)
}
if _, err := targetRepo.Reference(pullRef, true); err == nil {
t.Errorf("expected refs/pull/1/head NOT on target")
}
}
func TestRun_Fetch_AllRefsCoversTagsAndOtherKind(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
head, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve source head: %v", err)
}
tagRef := plumbing.NewTagReferenceName("v1")
if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(tagRef, head.Hash())); err != nil {
t.Fatalf("set source tag: %v", err)
}
notesRef := plumbing.ReferenceName("refs/notes/commits")
if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(notesRef, head.Hash())); err != nil {
t.Fatalf("set source notes ref: %v", err)
}
sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
defer sourceServer.Close()
output, err := captureStdout(func() error {
return run(context.Background(), []string{
"fetch",
"--all-refs",
"--json",
sourceServer.RepoURL(),
})
})
if err != nil {
t.Fatalf("run fetch --all-refs: %v\noutput=%s", err, output)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode fetch json: %v\noutput=%s", err, output)
}
wants, ok := result["wants"].([]any)
if !ok {
t.Fatalf("expected wants in result, got %#v", result)
}
seen := make(map[string]bool)
for _, raw := range wants {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
if name, ok := entry["name"].(string); ok && name != "" {
seen[name] = true
}
}
for _, want := range []string{string(tagRef), string(notesRef)} {
if !seen[want] {
t.Errorf("expected %s in fetch wants, got %v", want, seen)
}
}
}
// replicate's --all-refs must not bundle BestEffort the way sync's does.
func TestRun_Replicate_AllRefsKeepsStrictFailureOnNg(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
targetServer.receivePackHook = func(req *packp.UpdateRequests) *packp.ReportStatus {
return syncertest.DenyRefsReport(req, "deny updating a hidden ref")
}
err = run(context.Background(), []string{
modeReplicate,
"--all-refs",
"--json",
sourceServer.RepoURL(),
targetServer.RepoURL(),
})
if err == nil {
t.Fatal("expected replicate --all-refs to error on per-ref ng")
}
}
// Mirror of the replicate test: same target rejection, warning + exit 0
// for sync, so the CLI binding really differs by mode.
func TestRun_Sync_AllRefsWarnsOnNg(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
targetServer.receivePackHook = func(req *packp.UpdateRequests) *packp.ReportStatus {
return syncertest.DenyRefsReport(req, "deny updating a hidden ref")
}
output, err := captureStdout(func() error {
return run(context.Background(), []string{
"sync",
"--all-refs",
"--json",
sourceServer.RepoURL(),
targetServer.RepoURL(),
})
})
if err != nil {
t.Fatalf("expected sync --all-refs to succeed with warning, got: %v\noutput=%s", err, output)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode sync json: %v\noutput=%s", err, output)
}
if got, ok := result["warned"].(float64); !ok || got == 0 {
t.Fatalf("expected warned > 0 in result, got %#v", result["warned"])
}
}
func TestRun_Replicate_SubcommandRejectsForce(t *testing.T) {
err := run(context.Background(), []string{
modeReplicate,
133 unmodified lines
repo *git.Repository
repoPath string
// receivePackHook synthesizes the receive-pack response when set,
// bypassing the embedded ReceivePack handler. Used to simulate
// per-ref ng statuses from hostile targets.
receivePackHook func(*packp.UpdateRequests) *packp.ReportStatus
mu sync.Mutex
receivePacks int
thinCapable bool
125 unmodified lines
s.receivePacks++
s.mu.Unlock()
if s.receivePackHook != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req := packp.NewUpdateRequests()
if err := req.Decode(bytes.NewReader(body)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
report := s.receivePackHook(req)
// Wrap the report in sideband framing when negotiated, mirroring
// what transport.ReceivePack writes; the client's demuxer otherwise
// fails on raw report-status pkt-lines.
var buf bytes.Buffer
var writer io.Writer = &buf
useSideband := false
// Mirrors the syncer test server's sideband-wrap: no-progress
// turns off the wrapping even if a sideband cap is advertised.
if !req.Capabilities.Supports(capability.NoProgress) {
switch {
case req.Capabilities.Supports(capability.Sideband64k):
writer = sideband.NewMuxer(sideband.Sideband64k, &buf)
useSideband = true
case req.Capabilities.Supports(capability.Sideband):
writer = sideband.NewMuxer(sideband.Sideband, &buf)
useSideband = true
}
}
if err := report.Encode(nopWriteCloser{writer}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if useSideband {
_ = pktline.WriteFlush(&buf)
}
w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
if _, err := w.Write(buf.Bytes()); err != nil {
s.t.Fatalf("write receive-pack hook response: %v", err)
}
return
}
var buf bytes.Buffer
wc := nopWriteCloser{&buf}
Mcmd/git-sync/main_test.go+349
64 unmodified lines
65
66
67
68
69
70
71
72
64 unmodified lines
addTargetAuth(cmd, &targetAuth)
cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "include tag ref prefixes in probe")
cmd.Flags().BoolVar(&req.AllRefs, "all-refs", false, "advertise all refs/* prefixes (branches, tags, notes, pulls, custom namespaces) in the probe")
excludeRefPrefixFlag(cmd, &req.ExcludeRefPrefixes)
addProtocolFlag(cmd, &protocolVal)
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
Mcmd/git-sync/probe.go+2
110 unmodified lines
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
110 unmodified lines
cmd.Flags().BoolVar(&req.Policy.IncludeTags, "tags", false, "mirror tags")
cmd.Flags().BoolVar(&req.Policy.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
cmd.Flags().BoolVar(&req.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
// Tag inclusion is now handled at the library level (AllRefs implies
// it in BuildDesiredRefs). Replicate keeps strict failure semantics —
// its contract is "target refs match source," so BestEffort is not
// bundled there; sync/plan get it for the best-effort UX.
var implies []*bool
usage := allRefsUsageBestEffort
if defaultMode == gitsync.ModeReplicate {
usage = allRefsUsageStrict
} else {
implies = append(implies, &req.Policy.BestEffort)
}
allRefsFlag(cmd, usage, &req.Scope.AllRefs, implies...)
excludeRefPrefixFlag(cmd, &req.Scope.ExcludeRefPrefixes)
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
cmd.Flags().BoolVar(&req.Options.Progress, "progress", false, "show live per-side throughput on stderr (TTY only)")
Mcmd/git-sync/syncplan.go+13
154 unmodified lines
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
11 unmodified lines
230
231
232
175
233
234
235
236
154 unmodified lines
<target-url>
```
Mirror every ref namespace (notes, pulls, custom) on a best-effort basis:
```bash
git-sync sync \
--all-refs \
<source-url> \
<target-url>
```
`--all-refs` broadens the source ref discovery from `refs/heads/`+`refs/tags/`
to every `refs/*` namespace (branches, tags, `refs/notes/*`, `refs/pull/*`,
custom refs) and lets ref mappings target arbitrary namespaces. Tag
inclusion is implied — `RefScope.AllRefs` covers tags at the library level,
so `--tags` does not need to be combined with `--all-refs`.
For `sync` and `bootstrap` the flag also turns on best-effort failure
handling: when the target's `receive-pack` rejects an individual ref (e.g.
GitHub refusing writes to `refs/pull/*` hidden refs), the rejected ref
appears in the result with `action=warn` and the server's reason instead
of failing the whole sync. Pack-level transport or unpack failures remain
fatal, and so do source-side `upload-pack` failures — if the source server
advertises a hidden ref but refuses to serve a `want` for its tip (Gerrit
`refs/changes/*` is a common case), the fetch errors out with no per-ref
warn granularity. BestEffort only covers target-side `receive-pack`.
Trim noisy namespaces with `--exclude-ref-prefix` (repeatable). The
common case is mirroring an open-source GitHub repo where `--all-refs`
would otherwise pull every PR's fork commits via `refs/pull/*`:
```bash
git-sync sync \
--all-refs \
--exclude-ref-prefix refs/pull/ \
<source-url> \
<target-url>
```
`--exclude-ref-prefix` subtracts from auto-discovery (branches, tags, and
`--all-refs` namespaces) and from prune scope, so excluded refs are left
alone entirely: not pulled from source, not pushed to target, not pruned
from target. Explicit `--map` entries are not subject to this filter.
`replicate --all-refs` broadens the same scope but does NOT enable
best-effort. Replicate's contract is "target refs match source"; downgrading
rejected refs to warnings would let partial mirrors exit successfully,
which contradicts the command. Use `sync --all-refs` if you want
best-effort completeness against hostile targets.
`sync --all-refs` blocks updates to non-branch refs (notes, pulls, custom
namespaces) by default — those refs don't generally form fast-forward
chains, so the same `--force` opt-in that retargets tags is required to
update them. `replicate` doesn't run that check; its overwrite contract
covers other-kind refs without `--force`.
`SyncPolicy.BestEffort` is independent of scope and can be set without
`AllRefs` if a library caller wants per-ref warn semantics on a narrower
scope.
Force source-side protocol v2:
```bash
11 unmodified lines
- keys use `camelCase`
- refs and hashes are serialized as strings, not raw byte arrays
- top-level keys include `plans`, `pushed`, `skipped`, `blocked`, `deleted`, `dryRun`, `protocol`, and `stats`, plus `relay`, `relayMode`, `relayReason`, `batching`, `batchCount`, `plannedBatchCount`, and `tempRefs`
- top-level keys include `plans`, `pushed`, `skipped`, `blocked`, `deleted`, `warned`, `dryRun`, `protocol`, and `stats`, plus `relay`, `relayMode`, `relayReason`, `batching`, `batchCount`, `plannedBatchCount`, and `tempRefs`
- each item in `plans` includes stable string fields such as `branch`, `sourceRef`, `targetRef`, `sourceHash`, `targetHash`, `kind`, `action`, and `reason`
## Auth
Mdocs/usage.md+59/-1
25 unmodified lines
26
27
28
29
30
31
32
33
34
35
30
31
32
36
37
38
39
40
41
42
36
37
43
44
45
46
47
41
42
48
49
50
51
52
46
47
53
54
55
56
57
51
52
58
59
60
61
62
48 unmodified lines
111
112
113
114
115
116
117
28 unmodified lines
146
147
148
141
142
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
8 unmodified lines
174
175
176
177
178
179
180
181
182
183
163
184
185
186
187
9 unmodified lines
197
198
199
179
200
201
202
203
10 unmodified lines
214
215
216
217
218
219
220
8 unmodified lines
229
230
231
210
232
233
234
235
11 unmodified lines
247
248
249
250
251
252
253
254
255
233
256
257
258
259
25 unmodified lines
}
// Pusher wraps target-side receive-pack state behind a smaller execution API.
// When OnRejection is non-nil, per-ref ng statuses invoke it instead of erroring;
// pack-level unpack failure remains fatal.
//
// Returned by NewPusher as a pointer so callers can attach OnRejection after
// construction without worrying about whether downstream strategies have
// already captured a value copy.
type Pusher struct {
Conn *Conn
Adv *packp.AdvRefs
Verbose bool
Conn *Conn
Adv *packp.AdvRefs
Verbose bool
OnRejection func(refName plumbing.ReferenceName, status string)
}
// NewPusher builds a target-side push executor.
func NewPusher(conn *Conn, adv *packp.AdvRefs, verbose bool) Pusher {
return Pusher{Conn: conn, Adv: adv, Verbose: verbose}
func NewPusher(conn *Conn, adv *packp.AdvRefs, verbose bool) *Pusher {
return &Pusher{Conn: conn, Adv: adv, Verbose: verbose}
}
// PushPack streams a pack to the target.
func (p Pusher) PushPack(ctx context.Context, commands []PushCommand, pack io.ReadCloser) error {
return PushPack(ctx, p.Conn, p.Adv, commands, pack, p.Verbose)
func (p *Pusher) PushPack(ctx context.Context, commands []PushCommand, pack io.ReadCloser) error {
return PushPack(ctx, p.Conn, p.Adv, commands, pack, p.Verbose, p.OnRejection)
}
// PushCommands sends ref-only updates without a pack.
func (p Pusher) PushCommands(ctx context.Context, commands []PushCommand) error {
return PushCommands(ctx, p.Conn, p.Adv, commands, p.Verbose)
func (p *Pusher) PushCommands(ctx context.Context, commands []PushCommand) error {
return PushCommands(ctx, p.Conn, p.Adv, commands, p.Verbose, p.OnRejection)
}
// PushObjects encodes and pushes locally materialized objects.
func (p Pusher) PushObjects(ctx context.Context, commands []PushCommand, store storer.Storer, hashes []plumbing.Hash) error {
return PushObjects(ctx, p.Conn, p.Adv, commands, store, hashes, p.Verbose)
func (p *Pusher) PushObjects(ctx context.Context, commands []PushCommand, store storer.Storer, hashes []plumbing.Hash) error {
return PushObjects(ctx, p.Conn, p.Adv, commands, store, hashes, p.Verbose, p.OnRejection)
}
// buildUpdateRequest builds the receive-pack update request.
48 unmodified lines
req *packp.UpdateRequests,
packData io.Reader,
verbose bool,
onRejection func(plumbing.ReferenceName, string),
) error {
var header bytes.Buffer
if err := req.Encode(&header); err != nil {
28 unmodified lines
if err := report.Decode(respReader); err != nil {
return fmt.Errorf("decode report-status: %w", err)
}
if err := report.Error(); err != nil {
return fmt.Errorf("report-status: %w", err)
if onRejection == nil {
if err := report.Error(); err != nil {
return fmt.Errorf("report-status: %w", err)
}
return nil
}
if report.UnpackStatus != "" && report.UnpackStatus != "ok" {
return fmt.Errorf("report-status: unpack error: %s", report.UnpackStatus)
}
for _, cs := range report.CommandStatuses {
if cs.Status == "" || cs.Status == "ok" {
continue
}
onRejection(cs.ReferenceName, cs.Status)
}
}
return nil
8 unmodified lines
store storer.Storer,
hashes []plumbing.Hash,
verbose bool,
onRejection func(plumbing.ReferenceName, string),
) error {
req, _, hasUpdates, err := buildUpdateRequest(adv, commands, verbose)
if err != nil {
return err
}
if !hasUpdates {
return sendReceivePack(ctx, conn, req, nil, verbose)
return sendReceivePack(ctx, conn, req, nil, verbose, onRejection)
}
useRefDeltas := !adv.Capabilities.Supports(capability.OFSDelta)
9 unmodified lines
done <- pw.Close()
}()
err = sendReceivePack(ctx, conn, req, pr, verbose)
err = sendReceivePack(ctx, conn, req, pr, verbose, onRejection)
_ = pr.Close()
encodeErr := <-done
if err != nil {
10 unmodified lines
commands []PushCommand,
pack io.ReadCloser,
verbose bool,
onRejection func(plumbing.ReferenceName, string),
) error {
for _, cmd := range commands {
if cmd.Delete {
8 unmodified lines
return err
}
err = sendReceivePack(ctx, conn, req, pack, verbose)
err = sendReceivePack(ctx, conn, req, pack, verbose, onRejection)
closeErr := pack.Close()
if err != nil {
return err
11 unmodified lines
adv *packp.AdvRefs,
commands []PushCommand,
verbose bool,
onRejection func(plumbing.ReferenceName, string),
) error {
req, _, _, err := buildUpdateRequest(adv, commands, verbose)
if err != nil {
return err
}
return sendReceivePack(ctx, conn, req, nil, verbose)
return sendReceivePack(ctx, conn, req, nil, verbose, onRejection)
}
func progressWriter(verbose bool, dest io.Writer) io.Writer {
Minternal/gitproto/push.go+40/-17
146 unmodified lines
147
148
149
150
150
151
152
153
21 unmodified lines
175
176
177
178
178
179
180
181
24 unmodified lines
206
207
208
209
209
210
211
212
45 unmodified lines
258
259
260
261
261
262
263
264
65 unmodified lines
330
331
332
333
333
334
335
336
146 unmodified lines
err := PushPack(context.Background(), conn, adv, []PushCommand{{
Name: "refs/heads/main",
New: plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
}}, pack, false)
}}, pack, false, nil)
if err != nil {
t.Fatalf("PushPack returned error: %v", err)
}
21 unmodified lines
err := PushPack(context.Background(), conn, adv, []PushCommand{{
Name: "refs/heads/main",
New: plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
}}, pack, false)
}}, pack, false, nil)
if err == nil {
t.Fatal("expected PushPack to return an error")
}
24 unmodified lines
done <- PushPack(ctx, conn, adv, []PushCommand{{
Name: "refs/heads/main",
New: plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
}}, pack, false)
}}, pack, false, nil)
}()
select {
45 unmodified lines
done <- PushPack(context.Background(), conn, adv, []PushCommand{{
Name: "refs/heads/main",
New: plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
}}, pack, false)
}}, pack, false, nil)
}()
select {
65 unmodified lines
err = PushPack(context.Background(), conn, adv, []PushCommand{
{Name: "refs/heads/old", Delete: true},
}, pack, false)
}, pack, false, nil)
if err == nil {
t.Fatal("expected error for delete in pack push")
}
Minternal/gitproto/push_test.go+5/-5
17 unmodified lines
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
3 unmodified lines
37
38
39
40
41
42
43
19 unmodified lines
63
64
65
55
66
67
68
69
8 unmodified lines
78
79
80
81
82
83
84
85
86
87
88
89
76
90
91
92
93
94
95
78
96
97
98
99
100
101
102
81
103
104
105
106
107
108
109
110
111
112
2 unmodified lines
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
2 unmodified lines
132
133
134
135
136
99
100
101
102
103
104
105
106
107
108
109
137
138
139
140
58 unmodified lines
199
200
201
202
203
204
176
177
178
179
180
181
182
183
184
185
186
205
206
207
208
31 unmodified lines
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
51 unmodified lines
318
319
320
281
321
322
323
324
325
326
327
328
284
329
330
331
332
288
333
334
335
336
51 unmodified lines
388
389
390
346
347
348
391
392
393
394
17 unmodified lines
IncludeTags bool
Force bool
Prune bool
// AllRefs broadens the desired set to every refs/* on the source
// (notes, pulls, replace, custom namespaces) in addition to whatever
// branches/tags the existing flags select. Mappings can rename refs
// in any namespace when AllRefs is set; otherwise only refs/heads/
// and refs/tags/ are accepted.
AllRefs bool
// ExcludeRefPrefixes subtracts namespaces from auto-discovery. A ref
// whose name starts with any of these prefixes is not pulled, pushed,
// or pruned. Explicit Mappings are not subject to this filter.
ExcludeRefPrefixes []string
}
// BuildDesiredRefs constructs the set of desired refs and managed targets from
3 unmodified lines
sourceRefs map[plumbing.ReferenceName]plumbing.Hash,
cfg PlanConfig,
) (map[plumbing.ReferenceName]DesiredRef, map[plumbing.ReferenceName]ManagedTarget, error) {
cfg = normalizeAllRefs(cfg)
desired := make(map[plumbing.ReferenceName]DesiredRef)
managed := make(map[plumbing.ReferenceName]ManagedTarget)
19 unmodified lines
if len(cfg.Mappings) > 0 {
// Validate all mappings up front (issue #2, #3)
normalized, err := validation.ValidateMappings(cfg.Mappings)
normalized, err := validation.ValidateMappings(cfg.Mappings, cfg.AllRefs)
if err != nil {
return nil, nil, fmt.Errorf("validate ref mappings: %w", err)
}
8 unmodified lines
selected := SelectBranches(branches, cfg.Branches)
for branch, hash := range selected {
refName := plumbing.NewBranchReferenceName(branch)
if IsRefExcluded(refName, cfg.ExcludeRefPrefixes) {
continue
}
if err := addManaged(refName, refName, RefKindBranch, hash); err != nil {
return nil, nil, err
}
}
}
if cfg.IncludeTags {
// AllRefs implies tag inclusion. Both passes (tag, other-kind) walk
// sourceRefs once: under AllRefs+IncludeTags this saves a redundant
// iteration on repos with thousands of refs/changes/* or refs/notes/*.
wantTags := cfg.IncludeTags || cfg.AllRefs
if wantTags || cfg.AllRefs {
for refName, hash := range sourceRefs {
if !refName.IsTag() {
kind := RefKindFromName(refName)
switch {
case kind == RefKindTag && wantTags:
case kind == RefKindOther && cfg.AllRefs:
default:
continue
}
if err := addManaged(refName, refName, RefKindTag, hash); err != nil {
if IsRefExcluded(refName, cfg.ExcludeRefPrefixes) {
continue
}
if _, ok := desired[refName]; ok {
continue
}
if err := addManaged(refName, refName, kind, hash); err != nil {
return nil, nil, err
}
}
2 unmodified lines
return desired, managed, nil
}
// normalizeAllRefs zeros Branches under AllRefs so the desired-set and
// prune predicates agree on scope.
func normalizeAllRefs(cfg PlanConfig) PlanConfig {
if cfg.AllRefs {
cfg.Branches = nil
}
return cfg
}
// BuildPlans generates the action plans for each managed ref.
func BuildPlans(
store storer.EncodedObjectStorer,
2 unmodified lines
managed map[plumbing.ReferenceName]ManagedTarget,
cfg PlanConfig,
) ([]BranchPlan, error) {
cfg = normalizeAllRefs(cfg)
if cfg.Prune {
for targetRef := range targetRefs {
if _, ok := managed[targetRef]; ok {
continue
}
switch {
case targetRef.IsTag() && cfg.IncludeTags:
managed[targetRef] = ManagedTarget{Kind: RefKindTag, Label: targetRef.Short()}
case targetRef.IsBranch() && len(cfg.Mappings) == 0 && len(cfg.Branches) == 0:
managed[targetRef] = ManagedTarget{Kind: RefKindBranch, Label: targetRef.Short()}
}
}
addPruneCandidates(managed, targetRefs, cfg)
}
targetNames := make([]plumbing.ReferenceName, 0, len(managed))
58 unmodified lines
managed map[plumbing.ReferenceName]ManagedTarget,
cfg PlanConfig,
) ([]BranchPlan, error) {
cfg = normalizeAllRefs(cfg)
managed = copyManagedTargets(managed)
if cfg.Prune {
for targetRef := range targetRefs {
if _, ok := managed[targetRef]; ok {
continue
}
switch {
case targetRef.IsTag() && cfg.IncludeTags:
managed[targetRef] = ManagedTarget{Kind: RefKindTag, Label: targetRef.Short()}
case targetRef.IsBranch() && len(cfg.Mappings) == 0 && len(cfg.Branches) == 0:
managed[targetRef] = ManagedTarget{Kind: RefKindBranch, Label: targetRef.Short()}
}
}
addPruneCandidates(managed, targetRefs, cfg)
}
targetNames := make([]plumbing.ReferenceName, 0, len(managed))
31 unmodified lines
return plans, nil
}
// addPruneCandidates registers unmanaged target refs as deletion candidates
// within the user's current scope. cfg is assumed normalized.
func addPruneCandidates(managed map[plumbing.ReferenceName]ManagedTarget, targetRefs map[plumbing.ReferenceName]plumbing.Hash, cfg PlanConfig) {
for targetRef := range targetRefs {
if _, ok := managed[targetRef]; ok {
continue
}
if IsRefExcluded(targetRef, cfg.ExcludeRefPrefixes) {
continue
}
switch {
case targetRef.IsTag() && (cfg.IncludeTags || cfg.AllRefs):
managed[targetRef] = ManagedTarget{Kind: RefKindTag, Label: targetRef.Short()}
case targetRef.IsBranch() && len(cfg.Mappings) == 0 && len(cfg.Branches) == 0:
managed[targetRef] = ManagedTarget{Kind: RefKindBranch, Label: targetRef.Short()}
case cfg.AllRefs && RefKindFromName(targetRef) == RefKindOther && len(cfg.Mappings) == 0:
managed[targetRef] = ManagedTarget{Kind: RefKindOther, Label: targetRef.Short()}
}
}
}
func copyManagedTargets(input map[plumbing.ReferenceName]ManagedTarget) map[plumbing.ReferenceName]ManagedTarget {
out := make(map[plumbing.ReferenceName]ManagedTarget, len(input))
for k, v := range input {
51 unmodified lines
return plan, nil
}
if want.Kind == RefKindTag {
// Tags and other-kind refs (notes, pulls, custom namespaces) don't
// generally form fast-forward chains — a notes append creates a new
// commit that isn't an ancestor of the previous notes tip. Treat
// them the same way: require --force to retarget rather than
// running an ancestry check that would always fail.
if want.Kind == RefKindTag || want.Kind == RefKindOther {
if force {
plan.Action = ActionUpdate
plan.Reason = ShortHash(targetHash) + " -> " + ShortHash(want.SourceHash) + " (force tag update)"
plan.Reason = ShortHash(targetHash) + " -> " + ShortHash(want.SourceHash) + " (force " + string(want.Kind) + " update)"
return plan, nil
}
plan.Action = ActionBlock
plan.Reason = ShortHash(targetHash) + " differs from " + ShortHash(want.SourceHash) + "; use --force to retarget tag"
plan.Reason = ShortHash(targetHash) + " differs from " + ShortHash(want.SourceHash) + "; use --force to update " + string(want.Kind) + " ref " + want.TargetRef.String()
return plan, nil
}
51 unmodified lines
switch want.Kind {
case RefKindTag:
plan.Reason = ShortHash(targetHash) + " -> " + ShortHash(want.SourceHash) + " (replicate tag overwrite)"
case RefKindBranch:
plan.Reason = ShortHash(targetHash) + " -> " + ShortHash(want.SourceHash) + " (replicate overwrite)"
default:
case RefKindBranch, RefKindOther:
plan.Reason = ShortHash(targetHash) + " -> " + ShortHash(want.SourceHash) + " (replicate overwrite)"
}
return plan
Minternal/planner/planner.go+75/-32
162 unmodified lines
163
164
165
166
166
167
168
169
2 unmodified lines
172
173
174
175
175
176
177
178
2 unmodified lines
181
182
183
184
184
185
186
187
177 unmodified lines
365
366
367
368
369
370
371
6 unmodified lines
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
552 unmodified lines
1055
1056
1057
938
1058
1059
1060
1061
5 unmodified lines
1067
1068
1069
950
1070
1071
1072
1073
12 unmodified lines
1086
1087
1088
969
1089
1090
1091
1092
162 unmodified lines
_, err := validation.ValidateMappings([]RefMapping{
{Source: "main", Target: "stable"},
{Source: "release", Target: "stable"},
})
}, false)
if err == nil {
t.Fatalf("expected error for duplicate target")
}
2 unmodified lines
func TestValidateMappingsRejectsCrossKind(t *testing.T) {
_, err := validation.ValidateMappings([]RefMapping{
{Source: "refs/heads/main", Target: "refs/tags/main"},
})
}, false)
if err == nil {
t.Fatalf("expected error for cross-kind mapping")
}
2 unmodified lines
func TestValidateMappingsRejectsMixedQualification(t *testing.T) {
_, err := validation.ValidateMappings([]RefMapping{
{Source: "refs/heads/main", Target: "stable"},
})
}, false)
if err == nil {
t.Fatalf("expected error for mixed qualification")
}
177 unmodified lines
branchCount++
case RefKindTag:
tagCount++
case RefKindOther:
}
}
if branchCount != tt.wantBranchCount {
6 unmodified lines
}
}
func TestBuildDesiredRefsAllRefs(t *testing.T) {
hashBranch := plumbing.NewHash("1111111111111111111111111111111111111111")
hashTag := plumbing.NewHash("2222222222222222222222222222222222222222")
hashNotes := plumbing.NewHash("3333333333333333333333333333333333333333")
hashPull := plumbing.NewHash("4444444444444444444444444444444444444444")
sourceRefs := map[plumbing.ReferenceName]plumbing.Hash{
plumbing.NewBranchReferenceName("main"): hashBranch,
plumbing.NewTagReferenceName("v1.0"): hashTag,
plumbing.ReferenceName("refs/notes/commits"): hashNotes,
plumbing.ReferenceName("refs/pull/1/head"): hashPull,
}
t.Run("AllRefs covers branches, tags, and other-kind refs", func(t *testing.T) {
desired, _, err := BuildDesiredRefs(sourceRefs, PlanConfig{AllRefs: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// AllRefs implies tag inclusion: the contract is "every refs/*".
want := []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
plumbing.NewTagReferenceName("v1.0"),
plumbing.ReferenceName("refs/notes/commits"),
plumbing.ReferenceName("refs/pull/1/head"),
}
for _, ref := range want {
if _, ok := desired[ref]; !ok {
t.Errorf("expected %s in desired set", ref)
}
}
})
t.Run("Other-kind mapping accepted under AllRefs", func(t *testing.T) {
desired, _, err := BuildDesiredRefs(sourceRefs, PlanConfig{
Mappings: []RefMapping{{Source: "refs/notes/commits", Target: "refs/notes/mirror"}},
AllRefs: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := plumbing.ReferenceName("refs/notes/mirror")
got, ok := desired[want]
if !ok {
t.Fatalf("expected %s in desired set", want)
}
if got.Kind != RefKindOther {
t.Errorf("expected RefKindOther, got %s", got.Kind)
}
})
t.Run("AllRefs overrides Branches filter", func(t *testing.T) {
// Without AllRefs, the Branches filter narrows scope. With AllRefs,
// "all refs" must mean every branch — otherwise scope is asymmetric
// (filtered branches but all tags + all other).
filtered := map[plumbing.ReferenceName]plumbing.Hash{
plumbing.NewBranchReferenceName("main"): hashBranch,
plumbing.NewBranchReferenceName("dev"): hashBranch,
plumbing.NewTagReferenceName("v1.0"): hashTag,
}
desired, _, err := BuildDesiredRefs(filtered, PlanConfig{
Branches: []string{"main"},
AllRefs: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := desired[plumbing.NewBranchReferenceName("dev")]; !ok {
t.Error("expected dev branch in desired set despite Branches=[main] under AllRefs")
}
if _, ok := desired[plumbing.NewBranchReferenceName("main")]; !ok {
t.Error("expected main branch in desired set")
}
})
t.Run("ExcludeRefPrefixes subtracts from AllRefs scope", func(t *testing.T) {
desired, _, err := BuildDesiredRefs(sourceRefs, PlanConfig{
AllRefs: true,
ExcludeRefPrefixes: []string{"refs/pull/"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := desired[plumbing.ReferenceName("refs/pull/1/head")]; ok {
t.Error("expected refs/pull/1/head excluded")
}
if _, ok := desired[plumbing.ReferenceName("refs/notes/commits")]; !ok {
t.Error("expected refs/notes/commits to still be in scope")
}
if _, ok := desired[plumbing.NewBranchReferenceName("main")]; !ok {
t.Error("expected main branch to still be in scope")
}
})
t.Run("ExcludeRefPrefixes does not override explicit mappings", func(t *testing.T) {
// Mapping a notes ref while also excluding refs/notes/* — the mapping
// is explicit user intent and wins.
desired, _, err := BuildDesiredRefs(sourceRefs, PlanConfig{
Mappings: []RefMapping{{Source: "refs/notes/commits", Target: "refs/notes/mirror"}},
AllRefs: true,
ExcludeRefPrefixes: []string{"refs/notes/"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := desired[plumbing.ReferenceName("refs/notes/mirror")]; !ok {
t.Error("expected explicit mapping target to be in desired set")
}
})
t.Run("Other-kind mapping rejected without AllRefs", func(t *testing.T) {
_, _, err := BuildDesiredRefs(sourceRefs, PlanConfig{
Mappings: []RefMapping{{Source: "refs/notes/commits", Target: "refs/notes/mirror"}},
})
if err == nil {
t.Fatal("expected error mapping refs/notes/* without AllRefs")
}
})
}
func TestBuildPlansDelete(t *testing.T) {
hash1 := plumbing.NewHash("1111111111111111111111111111111111111111")
hash2 := plumbing.NewHash("2222222222222222222222222222222222222222")
552 unmodified lines
}
func TestValidateMappingsEmpty(t *testing.T) {
result, err := validation.ValidateMappings(nil)
result, err := validation.ValidateMappings(nil, false)
if err != nil {
t.Fatalf("expected nil error for empty mappings, got %v", err)
}
5 unmodified lines
func TestValidateMappingsValidBranch(t *testing.T) {
normalized, err := validation.ValidateMappings([]RefMapping{
{Source: "main", Target: "stable"},
})
}, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
12 unmodified lines
func TestValidateMappingsValidFullRef(t *testing.T) {
normalized, err := validation.ValidateMappings([]RefMapping{
{Source: "refs/heads/main", Target: "refs/heads/upstream-main"},
})
}, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Minternal/planner/planner_test.go+126/-6
96 unmodified lines
97
98
99
100
100
101
102
103
3 unmodified lines
107
108
109
110
110
111
112
113
48 unmodified lines
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
96 unmodified lines
if !plan.TargetHash.IsZero() {
return false, "incremental-branch-create-target-not-empty"
}
case ActionDelete, ActionSkip, ActionBlock:
case ActionDelete, ActionSkip, ActionBlock, ActionWarn:
return false, "incremental-branch-action-not-update-or-create"
}
case RefKindTag:
3 unmodified lines
if plan.Action != ActionCreate {
return false, "incremental-tag-action-not-create"
}
default:
case RefKindOther:
return false, "incremental-unsupported-ref-kind"
}
}
48 unmodified lines
if plan.Action != ActionCreate && plan.Action != ActionUpdate {
return false, "replicate-tag-action-not-create-or-update"
}
case RefKindOther:
// Replicate's contract is overwrite, so the FF concern that keeps
// other-kind refs out of the sync incremental relay doesn't apply
// here — a notes/pull ref update is just another ref-update relay.
if RefKindFromName(plan.SourceRef) != RefKindOther || RefKindFromName(plan.TargetRef) != RefKindOther {
return false, "replicate-non-other-mapping"
}
if plan.Action != ActionCreate && plan.Action != ActionUpdate {
return false, "replicate-other-action-not-create-or-update"
}
default:
return false, "replicate-unsupported-ref-kind"
}
Minternal/planner/relay.go+12/-2
9 unmodified lines
10
11
12
13
13
14
15
16
17
18
19
20
21
22
5 unmodified lines
28
29
30
31
32
33
34
35
36
65 unmodified lines
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
33 unmodified lines
172
173
174
147
148
149
175
176
177
178
179
180
181
151
152
182
183
184
185
186
7 unmodified lines
194
195
196
166
197
198
199
200
9 unmodified lines
"github.com/go-git/go-git/v6/plumbing"
)
// RefKind distinguishes branch refs from tag refs.
// RefKind distinguishes ref namespaces: branch, tag, or other (notes/pulls/custom).
type RefKind string
const (
RefKindBranch RefKind = "branch"
RefKindTag RefKind = "tag"
RefKindOther RefKind = "other"
)
// Action represents the planned operation on a ref.
5 unmodified lines
ActionDelete Action = "delete"
ActionSkip Action = "skip"
ActionBlock Action = "block"
// ActionWarn is set when the target rejected an individual ref under
// best-effort policy; the server's reason is carried in BranchPlan.Reason.
ActionWarn Action = "warn"
)
// RefMapping is a user-specified source:target mapping.
65 unmodified lines
return s
}
// IsRefExcluded reports whether name matches any of the exclude prefixes.
// Empty prefixes are ignored. Used to subtract specific namespaces from
// auto-discovery (e.g. refs/pull/* under --all-refs against GitHub).
func IsRefExcluded(name plumbing.ReferenceName, excludePrefixes []string) bool {
if len(excludePrefixes) == 0 {
return false
}
s := name.String()
for _, p := range excludePrefixes {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// RefKindFromName infers the ref kind from a fully qualified ref name.
// Returns RefKindOther for any refs/* outside refs/heads/ and refs/tags/,
// and "" for names that don't start with refs/ at all.
func RefKindFromName(name plumbing.ReferenceName) RefKind {
switch {
case name.IsBranch():
return RefKindBranch
case name.IsTag():
return RefKindTag
case strings.HasPrefix(name.String(), "refs/"):
return RefKindOther
default:
return ""
}
33 unmodified lines
return selected
}
// RefPrefixes computes the ref-prefix arguments for v2 ls-refs based on
// the user's configuration.
func RefPrefixes(mappings []RefMapping, includeTags bool) []string {
// RefPrefixes computes the ref-prefix arguments for v2 ls-refs from cfg.
// Under AllRefs the result collapses to a single "refs/" prefix.
func RefPrefixes(cfg PlanConfig) []string {
if cfg.AllRefs {
return []string{"refs/"}
}
prefixSet := map[string]struct{}{}
if len(mappings) > 0 {
for _, m := range mappings {
if len(cfg.Mappings) > 0 {
for _, m := range cfg.Mappings {
src := strings.TrimSpace(m.Source)
switch {
case strings.HasPrefix(src, "refs/tags/"):
7 unmodified lines
} else {
prefixSet["refs/heads/"] = struct{}{}
}
if includeTags {
if cfg.IncludeTags {
prefixSet["refs/tags/"] = struct{}{}
}
prefixes := make([]string, 0, len(prefixSet))
Minternal/planner/types.go+38/-7
100 unmodified lines
101
102
103
104
104
105
106
107
108
109
110
117 unmodified lines
228
229
230
231
232
233
229
230
234
235
232
233
234
235
236
237
238
239
236
237
238
239
240
243
241
242
243
244
245
246
247
248
372 unmodified lines
621
622
623
625
626
627
624
625
626
627
629
628
629
631
630
631
633
632
633
635
634
635
636
638
637
638
640
639
640
641
643
642
643
644
645
646
648
647
648
649
651
650
651
652
653
5 unmodified lines
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
100 unmodified lines
}
}
// Result holds the outcome of the bootstrap strategy.
// Result holds the outcome of the bootstrap strategy. Pushed is the count
// of attempted ref creates; under BestEffort, callers that want a count
// excluding rejected refs need to consult Pusher.OnRejection or apply the
// same downgrade pass the syncer wrapper does.
type Result struct {
Plans []planner.BranchPlan
Pushed int
117 unmodified lines
return result, errors.New("bootstrap batching requires protocol v2 source fetch filter support")
}
// Tags and other-kind refs are create-only and ride a single tail phase
// after the checkpointed branch batches; they reuse branch-tip haves.
planRefs := make([]planner.DesiredRef, 0, len(plans))
tagPlans := make([]planner.BranchPlan, 0, len(plans))
tagDesired := make(map[plumbing.ReferenceName]gitproto.DesiredRef)
tailPlans := make([]planner.BranchPlan, 0, len(plans))
for _, plan := range plans {
if plan.Kind == planner.RefKindTag {
tagPlans = append(tagPlans, plan)
if d, ok := p.DesiredRefs[plan.TargetRef]; ok {
tagDesired[plan.TargetRef] = gitproto.DesiredRef{
SourceRef: d.SourceRef, TargetRef: d.TargetRef,
SourceHash: d.SourceHash, IsTag: true,
}
}
if plan.Kind == planner.RefKindTag || plan.Kind == planner.RefKindOther {
tailPlans = append(tailPlans, plan)
continue
}
if !plan.SourceRef.IsBranch() || !plan.TargetRef.IsBranch() {
return result, errors.New("bootstrap batching currently supports branch refs and create-only tags")
return result, errors.New("bootstrap batching currently supports branch refs, tags, and other-kind create-only refs")
}
planRefs = append(planRefs, p.DesiredRefs[plan.TargetRef])
}
tailDesired := convert.DesiredRefsForPlans(p.DesiredRefs, tailPlans)
var batches []plannedBatch
if len(planRefs) > 0 {
372 unmodified lines
p.log("bootstrap batch branch finalized", "branch", batch.Plan.TargetRef.String())
}
// Tag phase (issue #1)
if len(tagPlans) > 0 {
p.log("bootstrap batch pushing tags after branch batches", "tag_count", len(tagPlans))
// Tail phase: tags and other-kind refs (issue #1)
if len(tailPlans) > 0 {
p.log("bootstrap batch pushing tail refs after branch batches", "tail_count", len(tailPlans))
if p.OnPhase != nil {
p.OnPhase("pushing tags")
p.OnPhase(tailPhaseLabel(tailPlans))
}
tagTargetRefs := planner.CopyRefHashMap(p.TargetRefs)
tailTargetRefs := planner.CopyRefHashMap(p.TargetRefs)
for _, batch := range batches {
tagTargetRefs[batch.Plan.TargetRef] = batch.Plan.SourceHash
tailTargetRefs[batch.Plan.TargetRef] = batch.Plan.SourceHash
}
packReader, err := p.SourceService.FetchPack(ctx, p.SourceConn, tagDesired, tagTargetRefs)
packReader, err := p.SourceService.FetchPack(ctx, p.SourceConn, tailDesired, tailTargetRefs)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
cmds := convert.PlansToPushCommands(tagPlans)
cmds := convert.PlansToPushCommands(tailPlans)
if err := p.TargetPusher.PushCommands(ctx, cmds); err != nil {
return result, fmt.Errorf("create tag refs after bootstrap: %w", err)
return result, fmt.Errorf("create tail refs after bootstrap: %w", err)
}
} else {
return result, fmt.Errorf("fetch bootstrap tag pack: %w", err)
return result, fmt.Errorf("fetch bootstrap tail pack: %w", err)
}
} else {
packReader = gitproto.LimitPackReader(packReader, p.MaxPackBytes)
packReader = closeOnce(packReader)
cmds := convert.PlansToPushCommands(tagPlans)
cmds := convert.PlansToPushCommands(tailPlans)
if err := p.TargetPusher.PushPack(ctx, cmds, packReader); err != nil {
_ = packReader.Close()
return result, fmt.Errorf("push bootstrap tags: %w", err)
return result, fmt.Errorf("push bootstrap tail refs: %w", err)
}
_ = packReader.Close()
}
5 unmodified lines
return result, nil
}
// tailPhaseLabel returns a phase label matching what's in plans.
func tailPhaseLabel(plans []planner.BranchPlan) string {
hasTag, hasOther := false, false
for _, plan := range plans {
switch plan.Kind {
case planner.RefKindTag:
hasTag = true
case planner.RefKindOther:
hasOther = true
case planner.RefKindBranch:
}
}
switch {
case hasTag && hasOther:
return "pushing tail refs"
case hasOther:
return "pushing other refs"
default:
return "pushing tags"
}
}
// --- Checkpoint planning ---
func planBatches(ctx context.Context, p Params, desired []planner.DesiredRef) ([]plannedBatch, error) {
Minternal/strategy/bootstrap/bootstrap.go+45/-24
52 unmodified lines
53
54
55
56
57
58
59
56
57
58
59
60
61
52 unmodified lines
updatePlans = append(updatePlans, plan)
case planner.ActionDelete:
deletePlans = append(deletePlans, plan)
case planner.ActionSkip, planner.ActionBlock:
// not applicable
default:
return Result{}, fmt.Errorf("replicate strategy does not support %s actions", plan.Action)
case planner.ActionSkip, planner.ActionBlock, planner.ActionWarn:
// not applicable: replicate runs before any rejection downgrade,
// and skip/block plans never reach the executor.
}
}
Minternal/strategy/replicate/replicate.go+3/-4
65 unmodified lines
66
67
68
69
69
70
71
72
198 unmodified lines
271
272
273
274
274
275
276
277
241 unmodified lines
519
520
521
522
522
523
524
525
90 unmodified lines
616
617
618
619
619
620
621
622
65 unmodified lines
if result.Pushed != 1 || result.Blocked != 0 {
t.Fatalf("unexpected initial result: %+v", result)
}
if !result.Relay || result.RelayMode != "bootstrap" {
if !result.Relay || result.RelayMode != relayModeBootstrap {
t.Fatalf("expected initial empty-target sync to use bootstrap relay, got %+v", result)
}
198 unmodified lines
if result.Pushed != 1 || result.Blocked != 0 {
t.Fatalf("unexpected initial mapped result: %+v", result)
}
if !result.Relay || result.RelayMode != "bootstrap" {
if !result.Relay || result.RelayMode != relayModeBootstrap {
t.Fatalf("expected initial mapped sync to use bootstrap relay, got %+v", result)
}
241 unmodified lines
if err != nil {
t.Fatalf("create source transport: %v", err)
}
sourceRefs, sourceService, err := gitproto.ListSourceRefs(context.Background(), sourceConn, cfg.ProtocolMode, planner.RefPrefixes(cfg.Mappings, cfg.IncludeTags))
sourceRefs, sourceService, err := gitproto.ListSourceRefs(context.Background(), sourceConn, cfg.ProtocolMode, planner.RefPrefixes(planConfig(cfg)))
if err != nil {
t.Fatalf("list source refs: %v", err)
}
90 unmodified lines
if err != nil {
t.Fatalf("create source transport: %v", err)
}
sourceRefs, sourceService, err := gitproto.ListSourceRefs(context.Background(), sourceConn, cfg.ProtocolMode, planner.RefPrefixes(cfg.Mappings, cfg.IncludeTags))
sourceRefs, sourceService, err := gitproto.ListSourceRefs(context.Background(), sourceConn, cfg.ProtocolMode, planner.RefPrefixes(planConfig(cfg)))
if err != nil {
t.Fatalf("list source refs: %v", err)
}
Minternal/syncer/git_http_backend_test.go+4/-4
37 unmodified lines
38
39
40
41
42
43
44
2736 unmodified lines
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
37 unmodified lines
testBranch = "master"
reasonEmptyTargetManagedRefs = "empty-target-managed-refs"
relayModeIncremental = "incremental"
relayModeBootstrap = "bootstrap"
relayModeBootstrapBatch = "bootstrap-batch"
)
2736 unmodified lines
}
}
func TestRun_IntegrationAllRefsBootstrapsCustomNamespace(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
notesRef := plumbing.ReferenceName("refs/notes/commits")
head := syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
sourceServer := newSmartHTTPRepoServerV2(t, sourceRepo)
targetServer := newSmartHTTPRepoServer(t, targetRepo)
defer sourceServer.Close()
defer targetServer.Close()
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeV2,
AllRefs: true,
})
if err != nil {
t.Fatalf("all-refs sync failed: %v", err)
}
if result.Pushed == 0 {
t.Fatalf("expected at least one ref pushed, got %+v", result)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target: %v", err)
}
if gotNotes.Hash() != head {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), head)
}
assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}
// --exclude-ref-prefix trims namespaces from --all-refs auto-discovery. The
// GitHub use case is `--all-refs --exclude-ref-prefix refs/pull/`: mirror
// branches/tags/notes but skip the fork-commit blowup from PR refs.
func TestRun_IntegrationAllRefsExcludesRefPrefix(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
ExcludeRefPrefixes: []string{"refs/pull/"},
})
if err != nil {
t.Fatalf("sync --all-refs --exclude-ref-prefix failed: %v", err)
}
if result.Pushed == 0 {
t.Fatalf("expected at least one ref pushed (branch + notes), got %+v", result)
}
if _, err := targetRepo.Reference(notesRef, true); err != nil {
t.Errorf("expected refs/notes/commits on target, got err=%v", err)
}
if _, err := targetRepo.Reference(pullRef, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
t.Errorf("expected refs/pull/1/head NOT on target, got err=%v", err)
}
}
// AllRefs other-kind plans fail CanIncrementalRelay and fall through to the
// materialized executor; this exercises that path end-to-end.
func TestRun_IntegrationAllRefsMaterializedPathIntoExistingTarget(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 3)
notesRef := plumbing.ReferenceName("refs/notes/commits")
head := syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
// Pre-populate target with the branch so this is a non-bootstrap path.
if err := copyRefsAndObjects(sourceRepo.Storer, targetRepo.Storer, []plumbing.ReferenceName{plumbing.NewBranchReferenceName(testBranch)}); err != nil {
t.Fatalf("copy target baseline: %v", err)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeV2,
AllRefs: true,
})
if err != nil {
t.Fatalf("all-refs sync into existing target failed: %v", err)
}
// Branch is a skip (target already current); only the notes ref pushes.
if result.Pushed != 1 {
t.Fatalf("expected Pushed=1 (notes ref create), got %d (result: %+v)", result.Pushed, result)
}
if result.Relay {
t.Errorf("expected materialized path (Relay=false) for other-kind ref, got Relay=true mode=%q", result.RelayMode)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target: %v", err)
}
if gotNotes.Hash() != head {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), head)
}
}
func TestRun_IntegrationAllRefsBestEffortDowngradesNgToWarn(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
notesRef := plumbing.ReferenceName("refs/notes/commits")
syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
targetServer.receivePackHook = func(req *packp.UpdateRequests, _ bool) *packp.ReportStatus {
return syncertest.DenyRefsReport(req, "deny updating a hidden ref", notesRef)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeV2,
AllRefs: true,
BestEffort: true,
})
if err != nil {
t.Fatalf("expected best-effort sync to succeed despite ng: %v", err)
}
if result.Warned != 1 {
t.Fatalf("expected Warned=1, got %d (result: %+v)", result.Warned, result)
}
var foundWarn bool
for _, plan := range result.Plans {
if plan.TargetRef == notesRef {
if plan.Action != ActionWarn {
t.Errorf("expected notes ref Action=warn, got %s", plan.Action)
}
if !strings.Contains(plan.Reason, "deny updating a hidden ref") {
t.Errorf("expected rejection reason in plan.Reason, got %q", plan.Reason)
}
foundWarn = true
}
}
if !foundWarn {
t.Fatal("expected to find the notes ref in result.Plans")
}
}
// AllRefs scope must not disable relay when the resulting push plan is
// branch-only (source notes ref already current on target).
func TestRun_IntegrationAllRefsIncrementalRelayWithBranchOnlyPush(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
notesRef := plumbing.ReferenceName("refs/notes/commits")
preNotesHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve pre-update head: %v", err)
}
if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(notesRef, preNotesHead.Hash())); err != nil {
t.Fatalf("set source notes ref: %v", err)
}
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
// Pre-populate target with both the branch and the notes ref so they
// match source. The incoming sync only needs to push branch updates.
if err := copyRefsAndObjects(sourceRepo.Storer, targetRepo.Storer, []plumbing.ReferenceName{plumbing.NewBranchReferenceName(testBranch), notesRef}); err != nil {
t.Fatalf("copy target baseline: %v", err)
}
makeCommits(t, sourceRepo, sourceFS, 1)
sourceServer := newSmartHTTPRepoServerV2(t, sourceRepo)
targetServer := newSmartHTTPRepoServer(t, targetRepo)
targetServer.receivePackThinCap = true
defer sourceServer.Close()
defer targetServer.Close()
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
})
if err != nil {
t.Fatalf("all-refs branch-only sync failed: %v", err)
}
if !result.Relay || result.RelayMode != relayModeIncremental {
t.Fatalf("expected incremental relay despite AllRefs scope, got mode=%q reason=%q relay=%v", result.RelayMode, result.RelayReason, result.Relay)
}
if result.Pushed != 1 {
t.Fatalf("expected Pushed=1 (branch update), got %d (result: %+v)", result.Pushed, result)
}
assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}
// Batched bootstrap routes other-kind refs through the same tail phase
// that handles tags, after the checkpointed branch batches finish.
func TestBootstrap_IntegrationAllRefsBatchedTailPhase(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeLargeCommits(t, sourceRepo, sourceFS, 5, 200_000)
notesRef := plumbing.ReferenceName("refs/notes/commits")
head := syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
result, err := Bootstrap(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
TargetMaxPackBytes: 350_000,
})
if err != nil {
t.Fatalf("batched all-refs bootstrap failed: %v", err)
}
if !result.Batching {
t.Fatalf("expected batched bootstrap, got %+v", result)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target after batched bootstrap: %v", err)
}
if gotNotes.Hash() != head {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), head)
}
assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}
// Batched bootstrap + AllRefs + BestEffort is the most complex
// --all-refs path: large source pack forces TargetMaxPackBytes batching,
// the tail phase pushes other-kind refs after checkpointed branch
// batches, and the target ng's the notes ref. The OnRejection callback
// must flow through *Pusher into bootstrap.Params.TargetPusher's
// interface boundary and downgrade the rejected ref to a warning.
func TestBootstrap_IntegrationAllRefsBatchedBestEffortDowngradesNg(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeLargeCommits(t, sourceRepo, sourceFS, 5, 200_000)
notesRef := plumbing.ReferenceName("refs/notes/commits")
syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
// Hook only fires on the tail-phase push (the request that contains
// the notes ref); branch-batch pushes pass through the real
// receive-pack handler so the target actually receives them.
targetServer.receivePackHook = func(req *packp.UpdateRequests, _ bool) *packp.ReportStatus {
hasNotes := false
for _, cmd := range req.Commands {
if cmd.Name == notesRef {
hasNotes = true
break
}
}
if !hasNotes {
return nil
}
return syncertest.DenyRefsReport(req, "deny updating a hidden ref", notesRef)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
BestEffort: true,
TargetMaxPackBytes: 350_000,
})
if err != nil {
t.Fatalf("batched all-refs best-effort sync failed: %v", err)
}
if !result.Batching {
t.Errorf("expected batched mode, got %+v", result)
}
if result.Warned != 1 {
t.Fatalf("expected Warned=1 (notes rejected), got %+v", result)
}
var foundWarn bool
for _, plan := range result.Plans {
if plan.TargetRef == notesRef {
if plan.Action != ActionWarn {
t.Errorf("expected notes Action=warn, got %s", plan.Action)
}
if !strings.Contains(plan.Reason, "deny updating a hidden ref") {
t.Errorf("expected ng reason in plan.Reason, got %q", plan.Reason)
}
foundWarn = true
}
}
if !foundWarn {
t.Fatal("notes ref missing from result.Plans")
}
assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}
// Other-kind refs don't have FF semantics (a notes append is rarely an
// ancestor of the previous notes tip), so PlanRef requires --force to
// retarget them — same as tags. This pins the block reason and the
// successful update under --force.
func TestRun_IntegrationAllRefsSyncOtherKindUpdateRequiresForce(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
notesRef := plumbing.ReferenceName("refs/notes/commits")
syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
sourceServer := newSmartHTTPRepoServerV2(t, sourceRepo)
targetServer := newSmartHTTPRepoServer(t, targetRepo)
defer sourceServer.Close()
defer targetServer.Close()
cfg := Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
}
if _, err := Run(context.Background(), cfg); err != nil {
t.Fatalf("initial all-refs sync failed: %v", err)
}
// Move the notes ref to a new, non-ancestor commit and try a plain sync.
makeCommits(t, sourceRepo, sourceFS, 1)
newHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve new head: %v", err)
}
if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(notesRef, newHead.Hash())); err != nil {
t.Fatalf("update source notes ref: %v", err)
}
result, err := Run(context.Background(), cfg)
if err == nil {
t.Fatal("expected sync to block on non-ancestor other-kind update")
}
var notesPlan *BranchPlan
for i := range result.Plans {
if result.Plans[i].TargetRef == notesRef {
notesPlan = &result.Plans[i]
}
}
if notesPlan == nil {
t.Fatalf("expected notes ref plan in result, got %+v", result.Plans)
}
if notesPlan.Action != ActionBlock {
t.Errorf("expected notes ref Action=%s, got %s", ActionBlock, notesPlan.Action)
}
if !strings.Contains(notesPlan.Reason, "use --force to update other ref") {
t.Errorf("expected clear --force-required reason for other-kind ref, got %q", notesPlan.Reason)
}
// Same scenario with --force succeeds.
cfg.Force = true
if _, err := Run(context.Background(), cfg); err != nil {
t.Fatalf("force-update of other-kind ref failed: %v", err)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target: %v", err)
}
if gotNotes.Hash() != newHead.Hash() {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), newHead.Hash())
}
}
// Sync's prune logic must extend to other-kind refs under AllRefs the same
// way replicate's does (covered in TestRun_IntegrationReplicateAllRefsPrune-
// SkipsBootstrapForStaleOtherRef). This pins the sync side: a stale notes
// ref on target with no source counterpart gets deleted under
// sync --all-refs --prune.
func TestRun_IntegrationAllRefsSyncPruneDeletesStaleOtherRef(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
if err := copyRefsAndObjects(sourceRepo.Storer, targetRepo.Storer, []plumbing.ReferenceName{plumbing.NewBranchReferenceName(testBranch)}); err != nil {
t.Fatalf("copy target baseline: %v", err)
}
staleHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve source head: %v", err)
}
staleNotes := plumbing.ReferenceName("refs/notes/stale")
if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(staleNotes, staleHead.Hash())); err != nil {
t.Fatalf("set stale notes ref on target: %v", err)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
AllRefs: true,
Prune: true,
})
if err != nil {
t.Fatalf("sync --all-refs --prune failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("expected Deleted=1 (stale notes), got %+v", result)
}
if _, err := targetRepo.Reference(staleNotes, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
t.Fatalf("expected %s pruned from target, got err=%v", staleNotes, err)
}
}
// Pure-prune replicate runs (no source-side updates) must actually delete
// the orphaned ref. The runReplicate gate previously required at least one
// relay plan, so delete-only scenarios silently no-op'd; this pins the
// broader gate (any push plan triggers executeReplicate) for the non-
// AllRefs branch case too.
func TestRun_IntegrationReplicatePruneDeleteOnlyRunsExecutor(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
if err := copyRefsAndObjects(sourceRepo.Storer, targetRepo.Storer, []plumbing.ReferenceName{plumbing.NewBranchReferenceName(testBranch)}); err != nil {
t.Fatalf("copy target baseline: %v", err)
}
staleHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve source head: %v", err)
}
orphanRef := plumbing.NewBranchReferenceName("stale-branch")
if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(orphanRef, staleHead.Hash())); err != nil {
t.Fatalf("set orphan branch: %v", err)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
Mode: modeReplicate,
Prune: true,
})
if err != nil {
t.Fatalf("delete-only replicate --prune failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("expected Deleted=1, got %+v", result)
}
if _, err := targetRepo.Reference(orphanRef, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
t.Fatalf("expected orphan branch to be pruned, got err=%v", err)
}
}
// Replicate's bootstrap shortcut must not fire when --prune --all-refs has
// stale other-kind refs to delete on target; otherwise replicate would
// claim "target matches source" while leaving orphaned refs/notes/* behind.
func TestRun_IntegrationReplicateAllRefsPruneSkipsBootstrapForStaleOtherRef(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 1)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
// Target has an orphaned notes ref that doesn't exist on source.
staleHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve source head: %v", err)
}
if err := copyRefsAndObjects(sourceRepo.Storer, targetRepo.Storer, []plumbing.ReferenceName{plumbing.NewBranchReferenceName(testBranch)}); err != nil {
t.Fatalf("copy target baseline: %v", err)
}
staleNotes := plumbing.ReferenceName("refs/notes/stale")
if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(staleNotes, staleHead.Hash())); err != nil {
t.Fatalf("set stale notes ref on target: %v", err)
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
Mode: modeReplicate,
AllRefs: true,
Prune: true,
})
if err != nil {
t.Fatalf("replicate --all-refs --prune failed: %v", err)
}
if result.RelayMode == relayModeBootstrap {
t.Fatalf("expected replicate to take prune path, not bootstrap; got RelayMode=%q", result.RelayMode)
}
if _, err := targetRepo.Reference(staleNotes, true); err == nil {
t.Fatalf("expected stale %s to be pruned from target", staleNotes)
} else if !errors.Is(err, plumbing.ErrReferenceNotFound) {
t.Fatalf("unexpected error resolving stale ref: %v", err)
}
}
// Replicate's relay covers other-kind refs (notes, pulls, custom namespaces)
// just like branches and tags — the overwrite semantics make the
// fast-forward concern that keeps them out of incremental sync relay
// irrelevant here. This pins idempotent re-runs: replicate --all-refs
// must keep working when a notes ref updates between runs.
func TestRun_IntegrationAllRefsReplicateUpdatesOtherKindOnSecondRun(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
notesRef := plumbing.ReferenceName("refs/notes/commits")
syncertest.SetRefAtBranch(t, sourceRepo, notesRef, testBranch)
targetRepo, err := git.Init(memory.NewStorage())
if err != nil {
t.Fatalf("init target repo: %v", err)
}
cfg := Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
ProtocolMode: protocolModeAuto,
Mode: modeReplicate,
AllRefs: true,
}
if _, err := Run(context.Background(), cfg); err != nil {
t.Fatalf("first replicate --all-refs failed: %v", err)
}
// Move the notes ref forward on the source and run replicate again —
// this used to fail with "replicate-unsupported-ref-kind".
makeCommits(t, sourceRepo, sourceFS, 1)
updatedHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
t.Fatalf("resolve updated source head: %v", err)
}
if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(notesRef, updatedHead.Hash())); err != nil {
t.Fatalf("update source notes ref: %v", err)
}
result, err := Run(context.Background(), cfg)
if err != nil {
t.Fatalf("second replicate --all-refs failed: %v", err)
}
if !result.Relay {
t.Errorf("expected relay path on second replicate, got Relay=false RelayMode=%q", result.RelayMode)
}
gotNotes, err := targetRepo.Reference(notesRef, true)
if err != nil {
t.Fatalf("expected refs/notes/commits on target: %v", err)
}
if gotNotes.Hash() != updatedHead.Hash() {
t.Fatalf("target notes hash = %s, want %s", gotNotes.Hash(), updatedHead.Hash())
}
}
func TestRun_IntegrationAllRefsRejectsCustomMappingWithoutAllRefs(t *testing.T) {
_, err := Run(context.Background(), Config{
Source: Endpoint{URL: "https://example.invalid/source.git"},
Target: Endpoint{URL: "https://example.invalid/target.git"},
ProtocolMode: protocolModeAuto,
Mappings: []RefMapping{{Source: "refs/notes/commits", Target: "refs/notes/mirror"}},
})
if err == nil {
t.Fatal("expected error when mapping refs/notes/* without AllRefs")
}
if !strings.Contains(err.Error(), "unsupported source ref kind") {
t.Fatalf("expected unsupported-kind error, got %v", err)
}
}
func newSourceRepo(t *testing.T) (*git.Repository, billy.Filesystem) {
return syncertest.NewMemoryRepo(t)
}
Minternal/syncer/integration_test.go+644
69 unmodified lines
70
71
72
73
74
75
76
77
3 unmodified lines
81
82
83
84
85
86
87
15 unmodified lines
103
104
105
106
107
108
109
110
111
112
113
114
115
20 unmodified lines
136
137
138
139
140
141
142
15 unmodified lines
158
159
160
155
156
161
162
163
164
165
220 unmodified lines
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
385
386
387
388
389
445
446
447
448
449
450
451
452
453
454
45 unmodified lines
500
501
502
503
504
505
506
507
23 unmodified lines
531
532
533
470
534
535
536
537
11 unmodified lines
549
550
551
488
552
553
554
555
22 unmodified lines
578
579
580
517
581
582
583
584
29 unmodified lines
614
615
616
617
618
619
620
621
622
623
624
625
120 unmodified lines
746
747
748
749
750
751
752
753
27 unmodified lines
781
782
783
712
713
714
715
716
717
718
719
720
721
784
785
786
787
64 unmodified lines
852
853
854
855
856
857
858
859
795
796
797
798
860
861
862
863
864
865
866
867
868
4 unmodified lines
873
874
875
809
810
811
812
813
814
815
816
817
818
876
877
878
879
15 unmodified lines
895
896
897
898
899
900
901
902
903
904
841
905
906
843
907
908
909
910
911
912
107 unmodified lines
1020
1021
1022
1023
1024
1025
1026
958
1027
1028
1029
1030
83 unmodified lines
1114
1115
1116
1117
1118
1119
1120
1121
1122
2 unmodified lines
1125
1126
1127
1056
1128
1129
1130
1131
69 unmodified lines
HTTPClient *http.Client
Branches []string
Mappings []RefMapping
AllRefs bool
ExcludeRefPrefixes []string
IncludeTags bool
DryRun bool
Verbose bool
3 unmodified lines
Mode string
Force bool
Prune bool
BestEffort bool
MaxPackBytes int64
TargetMaxPackBytes int64
MaterializedMaxObjects int
15 unmodified lines
const (
RefKindBranch = planner.RefKindBranch
RefKindTag = planner.RefKindTag
RefKindOther = planner.RefKindOther
ActionCreate = planner.ActionCreate
ActionUpdate = planner.ActionUpdate
ActionDelete = planner.ActionDelete
ActionSkip = planner.ActionSkip
ActionBlock = planner.ActionBlock
ActionWarn = planner.ActionWarn
)
type RefInfo struct {
20 unmodified lines
Skipped int `json:"skipped"`
Blocked int `json:"blocked"`
Deleted int `json:"deleted"`
Warned int `json:"warned"`
DryRun bool `json:"dryRun"`
OperationMode string `json:"operationMode"`
Relay bool `json:"relay"`
15 unmodified lines
lines = append(lines, planner.FormatPlanLine(plan))
}
summary := fmt.Sprintf(
"summary: pushed=%d deleted=%d skipped=%d blocked=%d mode=%s protocol=%s relay=%t relay-mode=%s relay-reason=%s batching=%t batch-count=%d planned-batches=%d",
r.Pushed, r.Deleted, r.Skipped, r.Blocked, r.OperationMode, r.Protocol, r.Relay, r.RelayMode, r.RelayReason, r.Batching, r.BatchCount, r.PlannedBatchCount,
"summary: pushed=%d deleted=%d skipped=%d blocked=%d warned=%d mode=%s protocol=%s relay=%t relay-mode=%s relay-reason=%s batching=%t batch-count=%d planned-batches=%d",
r.Pushed, r.Deleted, r.Skipped, r.Blocked, r.Warned, r.OperationMode, r.Protocol, r.Relay, r.RelayMode, r.RelayReason, r.Batching, r.BatchCount, r.PlannedBatchCount,
)
if r.DryRun {
summary += " dry-run=true"
220 unmodified lines
return &clone
}
// finalizeCounts applies any best-effort rejections to both the push slice
// and the result.Plans the caller will return, then tallies Pushed/Deleted
// counters from the (now-classified) push plans.
func (s *syncSession) finalizeCounts(pushPlans []BranchPlan, result *Result) {
if !s.cfg.DryRun {
if warned := s.applyRejections(pushPlans); warned > 0 {
s.applyRejections(result.Plans)
result.Warned += warned
}
}
pushed, deleted := tallyActions(pushPlans)
result.Pushed += pushed
result.Deleted += deleted
}
// tallyActions counts ref pushes and deletes from a classified plan slice.
// ActionWarn/Skip/Block don't contribute; rejections are tracked separately
// in Result.Warned via applyRejections.
func tallyActions(plans []BranchPlan) (pushed, deleted int) {
for _, plan := range plans {
switch plan.Action {
case ActionCreate, ActionUpdate:
pushed++
case ActionDelete:
deleted++
case ActionWarn, ActionSkip, ActionBlock:
}
}
return pushed, deleted
}
// applyRejections downgrades plans whose ref was rejected by the target to
// ActionWarn and returns the count.
func (s *syncSession) applyRejections(plans []BranchPlan) int {
if len(s.rejections) == 0 {
return 0
}
warned := 0
for i := range plans {
status, ok := s.rejections[plans[i].TargetRef]
if !ok {
continue
}
plans[i].Action = ActionWarn
if status == "" {
plans[i].Reason = "target rejected ref update"
} else {
plans[i].Reason = "target rejected ref update: " + status
}
warned++
}
return warned
}
func planConfig(cfg Config) planner.PlanConfig {
return planner.PlanConfig{
Branches: cfg.Branches,
Mappings: cfg.Mappings,
IncludeTags: cfg.IncludeTags,
Force: cfg.Force,
Prune: cfg.Prune,
Branches: cfg.Branches,
Mappings: cfg.Mappings,
IncludeTags: cfg.IncludeTags,
AllRefs: cfg.AllRefs,
ExcludeRefPrefixes: cfg.ExcludeRefPrefixes,
Force: cfg.Force,
Prune: cfg.Prune,
}
}
45 unmodified lines
target *targetSession
measurementDone func() Measurement
progress *progressReporter
// rejections records target ng statuses; nil unless BestEffort.
rejections map[plumbing.ReferenceName]string
}
// finish releases any resources owned by the session — currently the live
23 unmodified lines
refMap map[plumbing.ReferenceName]plumbing.Hash
features gitproto.TargetFeatures
policy planner.RelayTargetPolicy
pusher gitproto.Pusher
pusher *gitproto.Pusher
}
// newSession performs the shared setup: protocol validation, mapping validation,
11 unmodified lines
default:
return nil, fmt.Errorf("unsupported operation mode %q", cfg.Mode)
}
if _, err := validation.ValidateMappings(cfg.Mappings); err != nil {
if _, err := validation.ValidateMappings(cfg.Mappings, cfg.AllRefs); err != nil {
return nil, fmt.Errorf("validate mappings: %w", err)
}
if cfg.Mode == modeReplicate && cfg.Force {
22 unmodified lines
}
s.sourceConn.ProgressOut = &sessionStderr{s: s}
refPrefixes := planner.RefPrefixes(cfg.Mappings, cfg.IncludeTags)
refPrefixes := planner.RefPrefixes(planConfig(cfg))
sourceRefs, sourceService, err := gitproto.ListSourceRefs(ctx, s.sourceConn, cfg.ProtocolMode, refPrefixes)
if err != nil {
return nil, fmt.Errorf("list source refs: %w", err)
29 unmodified lines
},
pusher: gitproto.NewPusher(targetConn, targetAdv, cfg.Verbose),
}
if cfg.BestEffort {
s.rejections = make(map[plumbing.ReferenceName]string)
s.target.pusher.OnRejection = func(name plumbing.ReferenceName, status string) {
s.rejections[name] = status
}
}
}
// Start the live progress ticker only after auth resolution and the
120 unmodified lines
result.Skipped++
case ActionBlock:
result.Blocked++
case ActionWarn:
// not produced by planning; only set after a push by applyRejections.
}
}
27 unmodified lines
}
}
for _, plan := range pushPlans {
switch plan.Action {
case ActionCreate, ActionUpdate:
result.Pushed++
case ActionDelete:
result.Deleted++
case ActionSkip, ActionBlock:
// not applicable in this context
}
}
s.finalizeCounts(pushPlans, &result)
result.Stats = stats.snapshot()
result.Measurement = measurementDone()
return result, nil
64 unmodified lines
result.Skipped++
case ActionBlock:
result.Blocked++
case ActionWarn:
// not produced by planning; only set after a push by applyRejections.
}
}
if !s.cfg.DryRun && len(relayPlans) > 0 {
ok, reason := planner.CanReplicateRelay(relayPlans)
if !ok {
return result, fmt.Errorf("replicate requires relay-capable target: %s; use sync instead", reason)
if !s.cfg.DryRun && len(pushPlans) > 0 {
if len(relayPlans) > 0 {
ok, reason := planner.CanReplicateRelay(relayPlans)
if !ok {
return result, fmt.Errorf("replicate requires relay-capable target: %s; use sync instead", reason)
}
}
repResult, err := s.executeReplicate(ctx, desiredRefs, pushPlans)
if err != nil {
4 unmodified lines
result.RelayReason = repResult.RelayReason
}
for _, plan := range pushPlans {
switch plan.Action {
case ActionCreate, ActionUpdate:
result.Pushed++
case ActionDelete:
result.Deleted++
case ActionSkip, ActionBlock:
// not applicable in this context
}
}
s.finalizeCounts(pushPlans, &result)
result.Stats = s.stats.snapshot()
result.Measurement = s.measurementDone()
return result, nil
15 unmodified lines
if _, ok := desiredRefs[targetRef]; ok {
continue
}
if planner.IsRefExcluded(targetRef, s.cfg.ExcludeRefPrefixes) {
continue
}
// AllRefs overrides per-namespace allowlists: under "all refs" a
// stale branch matters even when a Branches filter is set.
branchScopeCovers := s.cfg.AllRefs || len(s.cfg.Branches) == 0
switch {
case targetRef.IsTag() && s.cfg.IncludeTags:
case targetRef.IsTag() && (s.cfg.IncludeTags || s.cfg.AllRefs):
return false
case targetRef.IsBranch() && len(s.cfg.Mappings) == 0 && len(s.cfg.Branches) == 0:
case targetRef.IsBranch() && len(s.cfg.Mappings) == 0 && branchScopeCovers:
return false
case s.cfg.AllRefs && planner.RefKindFromName(targetRef) == planner.RefKindOther && len(s.cfg.Mappings) == 0:
return false
}
}
107 unmodified lines
if err != nil {
return Result{}, fmt.Errorf("bootstrap execute: %w", err)
}
plans := bResult.Plans
warned := s.applyRejections(plans)
pushed, _ := tallyActions(plans)
return Result{
Plans: bResult.Plans, Pushed: bResult.Pushed, OperationMode: s.cfg.Mode,
Plans: plans, Pushed: pushed, Warned: warned, OperationMode: s.cfg.Mode,
Relay: bResult.Relay, RelayMode: bResult.RelayMode, RelayReason: bResult.RelayReason,
Batching: bResult.Batching, BatchCount: bResult.BatchCount,
PlannedBatchCount: bResult.PlannedBatchCount, TempRefs: bResult.TempRefs,
83 unmodified lines
func (s *syncSession) newProbeResult() ProbeResult {
refInfos := make([]RefInfo, 0, len(s.sourceRefMap))
for name, hash := range s.sourceRefMap {
if planner.IsRefExcluded(name, s.cfg.ExcludeRefPrefixes) {
continue
}
refInfos = append(refInfos, RefInfo{Name: name.String(), Hash: hash})
}
sort.Slice(refInfos, func(i, j int) bool { return refInfos[i].Name < refInfos[j].Name })
2 unmodified lines
SourceURL: s.cfg.Source.URL,
RequestedMode: s.cfg.ProtocolMode,
Protocol: s.sourceService.Protocol,
RefPrefixes: planner.RefPrefixes(s.cfg.Mappings, s.cfg.IncludeTags),
RefPrefixes: planner.RefPrefixes(planConfig(s.cfg)),
Capabilities: s.sourceService.Capabilities(),
Refs: refInfos,
Stats: s.stats.snapshot(),
Minternal/syncer/syncer.go+110/-38
4 unmodified lines
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
4 unmodified lines
"strings"
"testing"
"github.com/go-git/go-git/v6/plumbing"
bstrap "entire.io/entire/git-sync/internal/strategy/bootstrap"
)
func TestApplyRejectionsDowngradesAndCarriesReason(t *testing.T) {
notes := plumbing.ReferenceName("refs/notes/commits")
main := plumbing.NewBranchReferenceName("main")
plans := []BranchPlan{
{TargetRef: main, Action: ActionUpdate, Reason: "abc -> def"},
{TargetRef: notes, Action: ActionCreate, Reason: "abc -> <new>"},
}
s := &syncSession{rejections: map[plumbing.ReferenceName]string{
notes: "deny updating a hidden ref",
}}
warned := s.applyRejections(plans)
if warned != 1 {
t.Fatalf("expected warned=1, got %d", warned)
}
if plans[0].Action != ActionUpdate {
t.Errorf("expected non-rejected ref to keep Action=%s, got %s", ActionUpdate, plans[0].Action)
}
if plans[1].Action != ActionWarn {
t.Errorf("expected rejected ref Action=%s, got %s", ActionWarn, plans[1].Action)
}
if !strings.Contains(plans[1].Reason, "deny updating a hidden ref") {
t.Errorf("expected server reason in plans[1].Reason, got %q", plans[1].Reason)
}
}
func TestApplyRejectionsEmptyMapIsNoOp(t *testing.T) {
plans := []BranchPlan{{TargetRef: plumbing.NewBranchReferenceName("main"), Action: ActionUpdate}}
s := &syncSession{}
if got := s.applyRejections(plans); got != 0 {
t.Fatalf("expected warned=0 with no rejections, got %d", got)
}
if plans[0].Action != ActionUpdate {
t.Errorf("expected plans untouched, got Action=%s", plans[0].Action)
}
}
func TestFinalizeCountsTalliesPushedAndDeleted(t *testing.T) {
notes := plumbing.ReferenceName("refs/notes/commits")
main := plumbing.NewBranchReferenceName("main")
stale := plumbing.NewBranchReferenceName("stale")
pushPlans := []BranchPlan{
{TargetRef: main, Action: ActionUpdate},
{TargetRef: stale, Action: ActionDelete},
{TargetRef: notes, Action: ActionCreate},
}
result := Result{Plans: append([]BranchPlan{}, pushPlans...)}
s := &syncSession{rejections: map[plumbing.ReferenceName]string{
notes: "deny updating a hidden ref",
}}
s.finalizeCounts(pushPlans, &result)
if result.Pushed != 1 {
t.Errorf("expected Pushed=1 (main only; notes downgraded), got %d", result.Pushed)
}
if result.Deleted != 1 {
t.Errorf("expected Deleted=1, got %d", result.Deleted)
}
if result.Warned != 1 {
t.Errorf("expected Warned=1, got %d", result.Warned)
}
for _, plan := range result.Plans {
if plan.TargetRef == notes && plan.Action != ActionWarn {
t.Errorf("expected result.Plans notes ref Action=%s, got %s", ActionWarn, plan.Action)
}
}
}
func TestGitHubOwnerRepo(t *testing.T) {
stats := newStats(false)
conn, err := newConn(Endpoint{URL: "https://github.com/torvalds/linux.git"}, "source", stats, nil)
Minternal/syncer/syncer_test.go+69
11 unmodified lines
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
11 unmodified lines
git "github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/plumbing/protocol/packp"
"github.com/go-git/go-git/v6/storage/memory"
)
// SetRefAtBranch points an arbitrary ref (e.g. refs/notes/commits) at the
// current tip of branch and returns the resolved hash. Used by --all-refs
// integration tests to seed non-branch/non-tag refs.
func SetRefAtBranch(tb testing.TB, repo *git.Repository, ref plumbing.ReferenceName, branch string) plumbing.Hash {
tb.Helper()
head, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true)
if err != nil {
tb.Fatalf("resolve branch %q: %v", branch, err)
}
if err := repo.Storer.SetReference(plumbing.NewHashReference(ref, head.Hash())); err != nil {
tb.Fatalf("set ref %s: %v", ref, err)
}
return head.Hash()
}
// DenyRefsReport synthesizes a receive-pack report that ng's the named refs
// with the given status and oks every other ref in the request. With deny
// empty, every ref in the request is rejected. Used by tests that simulate
// hostile targets (GitHub-style hidden-ref refusals, etc.).
func DenyRefsReport(req *packp.UpdateRequests, status string, deny ...plumbing.ReferenceName) *packp.ReportStatus {
denySet := make(map[plumbing.ReferenceName]bool, len(deny))
for _, r := range deny {
denySet[r] = true
}
report := packp.NewReportStatus()
report.UnpackStatus = "ok"
for _, cmd := range req.Commands {
cmdStatus := "ok"
if len(denySet) == 0 || denySet[cmd.Name] {
cmdStatus = status
}
report.CommandStatuses = append(report.CommandStatuses, &packp.CommandStatus{
ReferenceName: cmd.Name,
Status: cmdStatus,
})
}
return report
}
// NewMemoryRepo creates an in-memory repository with a memfs-backed worktree.
func NewMemoryRepo(tb testing.TB) (*git.Repository, billy.Filesystem) {
tb.Helper()
Minternal/syncertest/repo.go+41
119 unmodified lines
120
121
122
123
124
123
124
125
126
127
128
8 unmodified lines
137
138
139
139
140
140
141
142
142
143
143
144
145
146
147
12 unmodified lines
160
161
162
162
163
164
165
166
2 unmodified lines
169
170
171
171
172
173
174
175
6 unmodified lines
182
183
184
185
186
187
188
189
190
191
192
193
187
194
195
189
196
197
198
199
200
201
119 unmodified lines
return plumbing.NewBranchReferenceName(raw)
}
// NormalizeMapping validates and normalizes a single ref mapping.
func NormalizeMapping(m RefMapping) (NormalizedMapping, error) {
// NormalizeMapping validates a single mapping. allowOther accepts
// refs/* outside refs/heads/ and refs/tags/.
func NormalizeMapping(m RefMapping, allowOther bool) (NormalizedMapping, error) {
src := strings.TrimSpace(m.Source)
dst := strings.TrimSpace(m.Target)
if src == "" || dst == "" {
8 unmodified lines
targetRef := plumbing.ReferenceName(dst)
srcKind := refKind(sourceRef)
dstKind := refKind(targetRef)
if srcKind == "" {
return NormalizedMapping{}, fmt.Errorf("unsupported source ref kind: %s", src)
if !allowOther && srcKind == kindOther {
return NormalizedMapping{}, fmt.Errorf("unsupported source ref kind: %s (set --all-refs to allow arbitrary refs/* namespaces)", src)
}
if dstKind == "" {
return NormalizedMapping{}, fmt.Errorf("unsupported target ref kind: %s", dst)
if !allowOther && dstKind == kindOther {
return NormalizedMapping{}, fmt.Errorf("unsupported target ref kind: %s (set --all-refs to allow arbitrary refs/* namespaces)", dst)
}
if srcKind != dstKind {
return NormalizedMapping{}, fmt.Errorf("cross-kind mapping not allowed: %s (%s) -> %s (%s)", src, srcKind, dst, dstKind)
12 unmodified lines
}
// ValidateMappings normalizes all mappings and rejects duplicate target refs.
func ValidateMappings(mappings []RefMapping) ([]NormalizedMapping, error) {
func ValidateMappings(mappings []RefMapping, allowOther bool) ([]NormalizedMapping, error) {
if len(mappings) == 0 {
return nil, nil
}
2 unmodified lines
targetSeen := make(map[plumbing.ReferenceName]string, len(mappings))
for _, m := range mappings {
nm, err := NormalizeMapping(m)
nm, err := NormalizeMapping(m, allowOther)
if err != nil {
return nil, err
}
6 unmodified lines
return normalized, nil
}
const (
kindBranch = "branch"
kindTag = "tag"
kindOther = "other"
)
func refKind(name plumbing.ReferenceName) string {
switch {
case name.IsBranch():
return "branch"
return kindBranch
case name.IsTag():
return "tag"
return kindTag
case strings.HasPrefix(name.String(), "refs/"):
return kindOther
default:
return ""
}
Minternal/validation/validation.go+19/-10
114 unmodified lines
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
114 unmodified lines
}
}
func TestNormalizeMappingAllowOther(t *testing.T) {
tests := []struct {
name string
mapping RefMapping
allowOther bool
wantErr bool
}{
{name: "notes ref blocked by default", mapping: RefMapping{Source: "refs/notes/commits", Target: "refs/notes/commits"}, wantErr: true},
{name: "notes ref allowed with allowOther", mapping: RefMapping{Source: "refs/notes/commits", Target: "refs/notes/commits"}, allowOther: true},
{name: "pull ref allowed with allowOther", mapping: RefMapping{Source: "refs/pull/1/head", Target: "refs/pull/1/head"}, allowOther: true},
{name: "cross-kind still blocked with allowOther", mapping: RefMapping{Source: "refs/heads/main", Target: "refs/notes/commits"}, allowOther: true, wantErr: true},
{name: "branch unchanged when allowOther", mapping: RefMapping{Source: "refs/heads/main", Target: "refs/heads/main"}, allowOther: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NormalizeMapping(tt.mapping, tt.allowOther)
if tt.wantErr {
if err == nil {
t.Fatalf("NormalizeMapping(%+v, %v) = nil, want error", tt.mapping, tt.allowOther)
}
return
}
if err != nil {
t.Fatalf("NormalizeMapping(%+v, %v) = %v, want nil", tt.mapping, tt.allowOther, err)
}
})
}
}
func TestParseHaveRef(t *testing.T) {
tests := []struct {
name string
Minternal/validation/validation_test.go+30
39 unmodified lines
40
41
42
43
44
43
44
45
46
47
48
49
1 unmodified line
51
52
53
54
55
56
57
55
58
59
57
58
59
60
61
60
61
62
63
64
65
66
67
68
69
8 unmodified lines
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
39 unmodified lines
}
type RefScope struct {
Branches []string
Mappings []RefMapping
Branches []string
Mappings []RefMapping
AllRefs bool
ExcludeRefPrefixes []string
}
type SyncPolicy struct {
1 unmodified line
IncludeTags bool
Force bool
Prune bool
BestEffort bool
Protocol ProtocolMode
}
func ProbeConfig(source Endpoint, sourceAuth EndpointAuth, target *Endpoint, targetAuth EndpointAuth, protocol ProtocolMode, includeTags, collectStats bool, httpClient *http.Client) Config {
func ProbeConfig(source Endpoint, sourceAuth EndpointAuth, target *Endpoint, targetAuth EndpointAuth, protocol ProtocolMode, includeTags, allRefs, collectStats bool, excludeRefPrefixes []string, httpClient *http.Client) Config {
cfg := syncer.Config{
Source: ToSyncerEndpoint(source, sourceAuth),
HTTPClient: httpClient,
IncludeTags: includeTags,
ShowStats: collectStats,
ProtocolMode: protocolString(protocol),
Source: ToSyncerEndpoint(source, sourceAuth),
HTTPClient: httpClient,
IncludeTags: includeTags,
AllRefs: allRefs,
ExcludeRefPrefixes: append([]string(nil), excludeRefPrefixes...),
ShowStats: collectStats,
ProtocolMode: protocolString(protocol),
}
if target != nil {
cfg.Target = ToSyncerEndpoint(*target, targetAuth)
8 unmodified lines
HTTPClient: httpClient,
Branches: append([]string(nil), scope.Branches...),
Mappings: ToValidationMappings(scope.Mappings),
AllRefs: scope.AllRefs,
ExcludeRefPrefixes: append([]string(nil), scope.ExcludeRefPrefixes...),
IncludeTags: policy.IncludeTags,
DryRun: dryRun,
ShowStats: collectStats,
Mode: operationModeString(policy.Mode),
Force: policy.Force,
Prune: policy.Prune,
BestEffort: policy.BestEffort,
ProtocolMode: protocolString(policy.Protocol),
MaterializedMaxObjects: syncer.DefaultMaterializedMaxObjects,
}}
Minternalbridge/config.go+16/-8
11 unmodified lines
12
13
14
15
16
17
18
4 unmodified lines
23
24
25
26
27
28
29
66 unmodified lines
96
97
98
99
100
101
102
50 unmodified lines
153
154
155
156
157
158
159
11 unmodified lines
const (
RefKindBranch RefKind = RefKind(planner.RefKindBranch)
RefKindTag RefKind = RefKind(planner.RefKindTag)
RefKindOther RefKind = RefKind(planner.RefKindOther)
)
type Action string
4 unmodified lines
ActionDelete Action = Action(planner.ActionDelete)
ActionSkip Action = Action(planner.ActionSkip)
ActionBlock Action = Action(planner.ActionBlock)
ActionWarn Action = Action(planner.ActionWarn)
)
type RefResult struct {
66 unmodified lines
Skipped int `json:"skipped"`
Blocked int `json:"blocked"`
Deleted int `json:"deleted"`
Warned int `json:"warned"`
}
type BatchSummary struct {
50 unmodified lines
Skipped: result.Skipped,
Blocked: result.Blocked,
Deleted: result.Deleted,
Warned: result.Warned,
},
Execution: ExecutionSummary{
DryRun: result.DryRun,
Minternalbridge/model.go+4
77 unmodified lines
78
79
80
81
81
82
83
84
85
86
87
83
84
88
89
90
91
92
93
87
94
95
96
97
98
99
100
101
102
103
104
105
106
98
99
100
101
102
107
108
109
110
111
112
113
114
115
116
19 unmodified lines
136
137
138
139
140
141
142
4 unmodified lines
147
148
149
150
151
152
153
77 unmodified lines
Target string `json:"target"`
}
// RefScope constrains which refs a request manages.
// RefScope constrains which refs a request manages. AllRefs broadens scope
// to every refs/* on the source (branches, tags, notes, pulls, custom
// namespaces) and implies SyncPolicy.IncludeTags. ExcludeRefPrefixes
// subtracts namespaces from auto-discovery (useful for trimming
// refs/pull/* when mirroring open-source GitHub repos under AllRefs);
// explicit Mappings are not subject to it.
type RefScope struct {
Branches []string `json:"branches"`
Mappings []RefMapping `json:"mappings"`
Branches []string `json:"branches"`
Mappings []RefMapping `json:"mappings"`
AllRefs bool `json:"allRefs,omitempty"`
ExcludeRefPrefixes []string `json:"excludeRefPrefixes,omitempty"`
}
// SyncPolicy controls high-level sync behavior.
// SyncPolicy controls high-level sync behavior. BestEffort downgrades per-ref
// receive-pack rejections to warnings; pack-level failures remain fatal.
type SyncPolicy struct {
Mode OperationMode `json:"mode"`
IncludeTags bool `json:"includeTags"`
Force bool `json:"force"`
Prune bool `json:"prune"`
BestEffort bool `json:"bestEffort,omitempty"`
Protocol ProtocolMode `json:"protocol"`
}
// ProbeRequest inspects source refs and optional target capabilities.
type ProbeRequest struct {
Source Endpoint `json:"source"`
Target *Endpoint `json:"target"`
IncludeTags bool `json:"includeTags"`
Protocol ProtocolMode `json:"protocol"`
CollectStats bool `json:"collectStats"`
Source Endpoint `json:"source"`
Target *Endpoint `json:"target"`
IncludeTags bool `json:"includeTags"`
AllRefs bool `json:"allRefs,omitempty"`
ExcludeRefPrefixes []string `json:"excludeRefPrefixes,omitempty"`
Protocol ProtocolMode `json:"protocol"`
CollectStats bool `json:"collectStats"`
}
// PlanRequest computes ref actions without pushing.
19 unmodified lines
const (
RefKindBranch RefKind = internalbridge.RefKindBranch
RefKindTag RefKind = internalbridge.RefKindTag
RefKindOther RefKind = internalbridge.RefKindOther
)
type Action = internalbridge.Action
4 unmodified lines
ActionDelete Action = internalbridge.ActionDelete
ActionSkip Action = internalbridge.ActionSkip
ActionBlock Action = internalbridge.ActionBlock
ActionWarn Action = internalbridge.ActionWarn
)
type RefResult = internalbridge.RefResult
Mtypes.go+22/-9
74 unmodified lines
75
76
77
78
79
80
81
82
78
79
80
81
82
83
84
85
86
87
10 unmodified lines
98
99
100
101
102
103
104
111 unmodified lines
216
217
218
216
217
218
219
220
221
222
223
219
220
221
222
223
224
225
226
227
228
229
230
231
24 unmodified lines
256
257
258
259
260
261
262
263
2 unmodified lines
266
267
268
269
270
271
272
18 unmodified lines
291
292
293
294
295
296
297
298
299
300
11 unmodified lines
312
313
314
304
305
306
307
308
309
310
311
312
315
316
317
318
319
320
321
322
323
324
325
326
327
328
74 unmodified lines
}
type ProbeRequest struct {
Source gitsync.Endpoint
Target *gitsync.Endpoint
IncludeTags bool
Protocol gitsync.ProtocolMode
Options AdvancedOptions
Source gitsync.Endpoint
Target *gitsync.Endpoint
IncludeTags bool
AllRefs bool
ExcludeRefPrefixes []string
Protocol gitsync.ProtocolMode
Options AdvancedOptions
}
type SyncRequest struct {
10 unmodified lines
Target gitsync.Endpoint
Scope gitsync.RefScope
IncludeTags bool
BestEffort bool
Protocol gitsync.ProtocolMode
Options AdvancedOptions
}
111 unmodified lines
return syncer.Config{}, err
}
cfg := syncer.Config{
Source: source,
HTTPClient: c.httpClient,
IncludeTags: req.IncludeTags,
ShowStats: req.Options.CollectStats,
MeasureMemory: req.Options.MeasureMemory,
Progress: req.Options.Progress,
ProtocolMode: protocolString(req.Protocol),
Verbose: req.Options.Verbose,
Source: source,
HTTPClient: c.httpClient,
IncludeTags: req.IncludeTags,
AllRefs: req.AllRefs,
ExcludeRefPrefixes: append([]string(nil), req.ExcludeRefPrefixes...),
ShowStats: req.Options.CollectStats,
MeasureMemory: req.Options.MeasureMemory,
Progress: req.Options.Progress,
ProtocolMode: protocolString(req.Protocol),
Verbose: req.Options.Verbose,
}
if req.Target != nil {
target, err := c.resolveEndpoint(ctx, *req.Target, gitsync.TargetRole)
24 unmodified lines
HTTPClient: c.httpClient,
Branches: append([]string(nil), req.Scope.Branches...),
Mappings: validationMappings(req.Scope.Mappings),
AllRefs: req.Scope.AllRefs,
ExcludeRefPrefixes: append([]string(nil), req.Scope.ExcludeRefPrefixes...),
IncludeTags: req.Policy.IncludeTags,
DryRun: req.DryRun,
ShowStats: req.Options.CollectStats,
2 unmodified lines
Mode: operationModeString(req.Policy.Mode),
Force: req.Policy.Force,
Prune: req.Policy.Prune,
BestEffort: req.Policy.BestEffort,
MaxPackBytes: req.Options.MaxPackBytes,
TargetMaxPackBytes: req.Options.TargetMaxPackBytes,
MaterializedMaxObjects: maxObjects,
18 unmodified lines
HTTPClient: c.httpClient,
Branches: append([]string(nil), req.Scope.Branches...),
Mappings: validationMappings(req.Scope.Mappings),
AllRefs: req.Scope.AllRefs,
ExcludeRefPrefixes: append([]string(nil), req.Scope.ExcludeRefPrefixes...),
IncludeTags: req.IncludeTags,
BestEffort: req.BestEffort,
ShowStats: req.Options.CollectStats,
MeasureMemory: req.Options.MeasureMemory,
Progress: req.Options.Progress,
11 unmodified lines
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,
Progress: req.Options.Progress,
ProtocolMode: protocolString(req.Protocol),
Verbose: req.Options.Verbose,
Source: source,
HTTPClient: c.httpClient,
Branches: append([]string(nil), req.Scope.Branches...),
AllRefs: req.Scope.AllRefs,
ExcludeRefPrefixes: append([]string(nil), req.Scope.ExcludeRefPrefixes...),
IncludeTags: req.IncludeTags,
ShowStats: req.Options.CollectStats,
MeasureMemory: req.Options.MeasureMemory,
Progress: req.Options.Progress,
ProtocolMode: protocolString(req.Protocol),
Verbose: req.Options.Verbose,
}, nil
}
Munstable/client.go+35/-22