Let replicate relay against targets advertising no-thin · Entire

Let replicate relay against targets advertising no-thin

1a477f8→main·

Soph·3mo ago·6 files·+68 added/-16 removed

Targets built on go-git's receive-pack (including entire-server) unconditionally advertise the no-thin capability because go-git has a "TODO: support thin-pack" in plumbing/transport/serve.go. Replicate previously rejected such targets with "use sync instead", which made the mode unusable against the most common internal target.

Reconsidered the constraint: our upload-pack client gitproto.fetchPackV1 / fetchPackV2 / fetchToStoreV1 never sets the "thin-pack" capability in the request. By protocol rules the source only emits thin packs when the client explicitly asks for them, so the pack we relay is always self-contained and safe to push to a no-thin receive-pack. The rejection was overcautious.

Changes:

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

Changes

6

8 unmodified lines

9
10
11
12
13
14
12
13
14
15
16
17
18
19

8 unmodified lines

- `git-sync replicate` subcommand and `git-sync plan --mode replicate` for
  source-authoritative, relay-only replication. Divergent branches and tags
  are retargeted against the source; `--prune` deletes orphan managed refs.
  Relay-only by design: incompatible targets (e.g. advertising `no-thin`)
  fail with a "use sync instead" message rather than falling back to a
  materialized push.
  Relay-only by design: no materialized fallback. Replicate works against
targets that advertise `no-thin` (including any server built on go-git's
receive-pack, e.g. `entire-server`) because the relayed pack is always
self-contained — our upload-pack client never requests the `thin-pack`
capability from the source.
- `gitsync.Client.Replicate` on the stable embedding surface.
- `gitsync.OperationMode`, `gitsync.ModeSync`, `gitsync.ModeReplicate`, and
  `SyncPolicy.Mode` for selecting the mode from library callers.

MCHANGELOG.md+5/-3

70 unmodified lines

71
72
73
74
74
75
76
77
78
79
80

70 unmodified lines

- `replicate`
  - source-authoritative overwrite planning
  - relay-only execution
  - no materialized fallback; incompatible targets fail and should use `sync`
  - no materialized fallback
  - works against targets that advertise `no-thin` (the relayed pack is
    always self-contained because our upload-pack client does not request
    the `thin-pack` capability)

The current transfer modes are:

Mdocs/architecture.md+4/-1

149 unmodified lines

150
151
152
153
154
155
156
157
158
159
29 unmodified lines

189
190
191
192
193
194
195
196
197
198
119 unmodified lines

318
319
320
321
322
323
324
325
326
327
328

149 unmodified lines

}

cmdArgs := make([]string, 0, len(wants)+len(haves)+4)
    // NOTE: no "thin-pack" argument. The relayed pack must stay
    // self-contained so callers (e.g. replicate) can forward it to
    // receive-pack servers that may advertise "no-thin". See
    // planner.SupportsReplicateRelay for the matching invariant.
    cmdArgs = append(cmdArgs, "ofs-delta", "no-progress")
    for _, h := range wants {
        cmdArgs = append(cmdArgs, "want "+h.String())
29 unmodified lines

}

cmdArgs := make([]string, 0, len(wants)+len(haves)+4)
    // NOTE: no "thin-pack" argument. The relayed pack must stay
    // self-contained so callers (e.g. replicate) can forward it to
    // receive-pack servers that may advertise "no-thin". See
    // planner.SupportsReplicateRelay for the matching invariant.
    cmdArgs = append(cmdArgs, "ofs-delta", "no-progress")
    // Only request include-tag if the server supports it (issue #6).
    if hasTag(desired) && caps.FetchSupports("include-tag") {
119 unmodified lines

if adv.Capabilities.Supports(capability.OFSDelta) {
        _ = req.Capabilities.Set(capability.OFSDelta)
    }
    // NOTE: we intentionally do not request capability.ThinPack. The relayed
    // pack must stay self-contained because callers (e.g. replicate) forward
    // it to receive-pack servers that may advertise "no-thin".
    // planner.SupportsReplicateRelay depends on this invariant — if you add
    // thin-pack support here, update that check to gate on target NoThin.

var buf bytes.Buffer
    if err := req.Encode(&buf); err != nil {

Minternal/gitproto/fetch.go+13

845 unmodified lines

846
847
848
849
850
851
852
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870

845 unmodified lines

}

func TestCanReplicateRelayRejectsNoThin(t *testing.T) {
    if ok, reason := SupportsReplicateRelay(RelayTargetPolicy{CapabilitiesKnown: true, NoThin: true}); ok {
        t.Fatal("expected SupportsReplicateRelay=false when target advertises no-thin")
    } else if reason != "replicate-target-no-thin" {
func TestSupportsReplicateRelayToleratesNoThin(t *testing.T) {
    // replicate tolerates "no-thin" targets because gitproto.FetchPack never
    // requests the thin-pack capability, so the relayed pack is always
    // self-contained and safe for no-thin receive-pack servers.
    ok, reason := SupportsReplicateRelay(RelayTargetPolicy{CapabilitiesKnown: true, NoThin: true})
    if !ok {
        t.Fatalf("expected SupportsReplicateRelay to accept no-thin target, got reason=%s", reason)
    }
    if reason != "replicate-target-capable-no-thin" {
        t.Fatalf("unexpected reason: %s", reason)
    }
}

func TestSupportsReplicateRelayRejectsUnknownCapabilities(t *testing.T) {
    ok, reason := SupportsReplicateRelay(RelayTargetPolicy{CapabilitiesKnown: false})
    if ok {
        t.Fatal("expected SupportsReplicateRelay=false when target capabilities are unknown")
    }
    if reason != "replicate-missing-target-capabilities" {
        t.Fatalf("unexpected reason: %s", reason)
    }
}

Minternal/planner/planner_test.go+19/-4

12 unmodified lines

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
21
29
30
31
32

12 unmodified lines

// SupportsReplicateRelay checks target-side capabilities required by
// replication mode before looking at planned ref actions.
//
// Replicate tolerates targets that advertise "no-thin" because our
// upload-pack client (gitproto.FetchPack) never requests the "thin-pack"
// capability, so the source never emits a thin pack. The relayed pack is
// always self-contained and safe to push to a no-thin receive-pack.
// If gitproto.FetchPack ever begins requesting thin-pack, this check must
// gain a matching fallback (omit the request, or explicitly advertise
// NoThin on the source request) when target.NoThin is set.
func SupportsReplicateRelay(target RelayTargetPolicy) (bool, string) {
    if !target.CapabilitiesKnown {
        return false, "replicate-missing-target-capabilities"
    }
    if target.NoThin {
        return false, "replicate-target-no-thin"
    }
    return true, "replicate-target-capable-no-thin"
}

Minternal/planner/relay.go+9/-1

1578 unmodified lines

1579
1580
1581
1582
1582
1583
1584
1585
1586
1587
1588
1589
1600
1601
1602
1597
1603
1604
1605
1606
1607
1602
1603
1608
1609
1610
1605
1606
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621

1578 unmodified lines

assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}

func TestRun_IntegrationReplicateRejectsNoThinTarget(t *testing.T) {
func TestRun_IntegrationReplicateAgainstNoThinTarget(t *testing.T) {
    // Replicate must tolerate targets that advertise no-thin. Source upload-pack
    // never receives a thin-pack request from us (see gitproto/fetch.go), so
    // the relayed pack is self-contained and acceptable to a no-thin
    // receive-pack. This is the main reason the capability was reconsidered:
    // go-git's own receive-pack advertises no-thin, and so does any server
    // built on it (e.g. entire-server).
    sourceRepo, sourceFS := newSourceRepo(t)
    makeCommits(t, sourceRepo, sourceFS, 1)
    makeCommits(t, sourceRepo, sourceFS, 2)

targetRepo, err := git.Init(memory.NewStorage())
    if err != nil {
6 unmodified lines

defer sourceServer.Close()
    defer targetServer.Close()

_, err = Run(context.Background(), Config{
        result, err := Run(context.Background(), Config{
        Source: Endpoint{URL: sourceServer.RepoURL()},
        Target: Endpoint{URL: targetServer.RepoURL()},
        Mode:   modeReplicate,
    })
    if err == nil {
        t.Fatal("expected replicate to fail against no-thin target")
    }
    if err != nil {
        t.Fatalf("replicate against no-thin target failed: %v", err)
    }
    if !strings.Contains(err.Error(), "use sync instead") || !strings.Contains(err.Error(), "replicate-target-no-thin") {
        t.Fatalf("unexpected replicate error: %v", err)
    }
    if result.OperationMode != modeReplicate {
        t.Fatalf("expected operation_mode=replicate, got %q", result.OperationMode)
    }
    if !result.Relay {
        t.Fatalf("expected relay execution against no-thin target, got %+v", result)
    }

assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}

func TestReplicateCanBootstrapRejectsPruneDeletes(t *testing.T) {