Library AllRefs implies tag inclusion; close the contract gap · Entire

Library AllRefs implies tag inclusion; close the contract gap

3acfb7b→main·

Soph·2mo ago·8 files·+97 added/-31 removed

The library docs and the unstable fetch CLI promised "every refs/*", but BuildDesiredRefs and addPruneCandidates excluded tags unless IncludeTags was set. The CLI sync/bootstrap wrappers papered over this by implying --tags, but library callers and fetch --all-refs got a narrower scope than the wording promised.

Move the tag-inclusion implication into the library so the contract is honest at every layer:

Drop the now-redundant CLI bundling: sync/bootstrap no longer set IncludeTags from --all-refs because the library covers it. SyncPolicy. BestEffort stays orthogonal so callers can opt into per-ref warn semantics on a narrower scope.

Updated the planner test that asserted the old (wrong) "tag should not appear without IncludeTags" behavior, and added a fetch CLI smoke test verifying fetch --all-refs puts tag and notes refs into the wants list without --tags being passed.

Sessions

a0c571b71c5fView transcript

Changes

8

73 unmodified lines

74
75
76
77
77
78
79
80

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, &req.IncludeTags)
    allRefsFlag(cmd, allRefsUsageBestEffort, &req.Scope.AllRefs, &req.BestEffort)
    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+1/-1

331 unmodified lines

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

331 unmodified lines

}
}

// fetch --all-refs must broaden discovery to tags and other-kind refs
// without needing --tags, matching the AllRefs contract at the library level.
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 {
        th.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, _ := entry["name"].(string); 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 must keep strict failure semantics — its contract is "target
// matches source" — so --all-refs must NOT bundle BestEffort the way it does
// for sync. A target ng on any ref must surface as a non-nil error.

Mcmd/git-sync/main_test.go+59

110 unmodified lines

111
112
113
114
115
116
114
115
116
117
118
119
120
121

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")
    // Replicate keeps strict failure semantics — its contract is "target
    // refs match source." BestEffort would let partial mirrors exit success.
    implies := []*bool{&req.Policy.IncludeTags}
    // 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
    }

Mcmd/git-sync/syncplan.go+5/-3

164 unmodified lines

165
166
167
168
169
170
171
172
173
174
175
168
169
170
171
172
173
174
175
176
177
178
179
180
181
1 unmodified line

183
184
185
183
184
185
186
187
188
189
190
191

164 unmodified lines

```

`--all-refs` broadens the source ref discovery from `refs/heads/`+`refs/tags/`
to every `refs/*` namespace and lets ref mappings target arbitrary
namespaces (`refs/notes/*`, `refs/pull/*`, custom refs). For `sync` and
`bootstrap` the flag also implies `--tags` (so the broader scope really is
"every refs/*") and 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.

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.

`replicate --all-refs` broadens the same scope but does NOT enable
best-effort. Replicate's contract is "target refs match source"; downgrading
1 unmodified line

which contradicts the command. Use `sync --all-refs` if you want
best-effort completeness against hostile targets.

Library callers can decouple the halves: `RefScope.AllRefs`,
`SyncPolicy.IncludeTags`, and `SyncPolicy.BestEffort` are independent.
The CLI bundles them under `--all-refs` for convenience.
`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:

Mdocs/usage.md+14/-11

78 unmodified lines

79
80
81
82
82
83
84
85
86

146 unmodified lines

234
235
236
235
237
238
239
240

78 unmodified lines

}
    
    if cfg.IncludeTags {
    // AllRefs implies tag inclusion: the contract is "every refs/* on the
    // source," so tags are part of the broadened scope by definition.
    if cfg.IncludeTags || cfg.AllRefs {
        for refName, hash := range sourceRefs {
            if !refName.IsTag() {
                continue
146 unmodified lines

continue
    }
        switch {
        case targetRef.IsTag() && cfg.IncludeTags:
        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()}

Minternal/planner/planner.go+4/-2

389 unmodified lines

390
391
392
393
393
394
395
396
397
398
399
400
398
399
400
401
402
403
404
402
403
404
405
406
407
408
409
405
406
407
408
409
410
411

389 unmodified lines

plumbing.ReferenceName("refs/pull/1/head"):   hashPull,
    }

t.Run("AllRefs adds non-branch non-tag refs", func(t *testing.T) {
        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)
            }
            // Branches still come in; tags need IncludeTags; "other" comes from AllRefs.
            if _, ok := desired[plumbing.ReferenceName("refs/notes/commits")]; !ok {
                t.Error("expected refs/notes/commits in desired set")
            // 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"),
            }
            if _, ok := desired[plumbing.ReferenceName("refs/pull/1/head")]; !ok {
                t.Error("expected refs/pull/1/head in desired set")
            }
            if _, ok := desired[plumbing.NewTagReferenceName("v1.0")]; ok {
                t.Error("tag should not appear without IncludeTags")
            }
            if _, ok := desired[plumbing.NewBranchReferenceName("main")]; !ok {
                t.Error("branch should still appear with default selection")
            for _, ref := range want {
                if _, ok := desired[ref]; !ok {
                    t.Errorf("expected %s in desired set", ref)
                }
            }
        })
}

Minternal/planner/planner_test.go+11/-12

879 unmodified lines

880
881
882
883
883
884
885
886

879 unmodified lines

continue
    }
    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:
        return false

Minternal/syncer/syncer.go+1/-1

78 unmodified lines

79
80
81
82
82
83
84
85
86

78 unmodified lines

// RefScope constrains which refs a request manages. AllRefs broadens scope
// to every refs/* on the source (notes, pulls, custom namespaces).
// to every refs/* on the source (branches, tags, notes, pulls, custom
// namespaces) and implies SyncPolicy.IncludeTags.
type RefScope struct {
    Branches []string     `json:"branches"`
    Mappings []RefMapping `json:"mappings"`
}

Mtypes.go+2/-1