# Let replicate relay against targets advertising no-thin

`1a477f8`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

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:

- planner.SupportsReplicateRelay no longer fails on target.NoThin. It
  returns ok with reason "replicate-target-capable-no-thin" so callers
  can still observe the distinction in logs and JSON.
- gitproto fetch code gets explicit comments at both the v1 and v2
  upload-request build sites documenting that we do not request
  thin-pack and that SupportsReplicateRelay depends on that
  invariant. Anyone adding thin-pack support later must update the
  planner check to gate on target NoThin.
- Integration test flipped: replicate against a no-thin target must
  now succeed and leave source and target heads matching, rather than
  fail with "use sync instead".
- Planner test split into "tolerates no-thin" and "rejects unknown
  capabilities" so the two branches of SupportsReplicateRelay each
  have direct coverage.
- CHANGELOG and docs/architecture.md updated to explain the new
  behavior and the thin-pack invariant.

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

## Changes

6

- MCHANGELOG.md+5/-3
  
- docs  
  
  - Marchitecture.md+4/-1

- internal  
  
  - gitproto  
  
    - Mfetch.go+13
  
  - planner  
  
    - Mplanner_test.go+19/-4
    - Mrelay.go+9/-1
  
  - syncer  
  
    - Mintegration_test.go+18/-7

```
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) {
