Merge pull request #44 from entireio/soph/all-refs · Entire

Home

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

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