can you review the changes in this branch

Claude Code·Opus 4.6[1m]·Soph·5mo ago·377hr 26min·23 Checkpoints·90 file changes·+2806/-961·3.2M tokens

can you review the changes in this branch

5mo ago·2m

Here's my review of the branch changes. The diff adds a new replicate operation mode (source-authoritative relay-only) across the CLI, library API, planner, and a new internal/strategy/replicate package.

Overall

Solid, well-tested addition. The split between sync (reconciliation) and replicate (overwrite) is clean, new code is mostly additive, and there's good test coverage at planner, strategy, session, and CLI levels. A few things worth addressing before merge.

Issues / nitpicks

1. Breaking JSON field rename — execution.modeexecution.transfer_mode (pkg/gitsync/internalbridge/model.go:98,145) ExecutionSummary.ModeTransferMode is a backwards-incompatible change to the stable embedding surface. If the stability promise matters for pkg/gitsync, consumers parsing execution.mode will silently break. Either (a) keep mode as a deprecated alias, (b) call this out in a CHANGELOG, or (c) confirm no external consumers yet.

2. Dead/confusing branch in defaultOperationMode (cmd/git-sync/main.go:412-424)

The name == "plan" branch is identical to the fallback. Just return operationMode(gitsync.ModeSync). Also — plan passes defaultMode="" intentionally, but sync and replicate always pass a value, so name is never needed. The helper can shrink to if defaultMode != "" { return … }; return operationMode(ModeSync).

3. BuildReplicationPlans mutates its caller's managed map (internal/planner/planner.go:174-185) The prune branch writes back into the passed-in map. The only caller happens not to reuse it after, but other planner functions (BuildPlans) don't do this. Either clone the map internally or document the side effect. This is the kind of thing that becomes a bug in 6 months.

4. dryRun argument in CanReplicateRelay is effectively dead (internal/planner/relay.go:122-124, called at internal/syncer/syncer.go:~632) The only call site is planner.CanReplicateRelay(false, relayPlans, …) — hardcoded. Either drop the parameter or call it from the dry-run path too. Currently it's confusing API surface pretending to support something it doesn't.

5. runReplicate allAbsent ignores managed refs that aren't in desiredRefs (internal/syncer/syncer.go:~555-565) allAbsent only scans desiredRefs. If prune is on and the target has managed refs to delete while every desired ref is absent, we jump into the bootstrap path, which doesn't handle the deletes. Probably low-incidence (empty-target implies empty managed set on first run), but worth either asserting or adding a test for replicate --prune against an "empty except for orphaned managed ref" target.

6. SupportsReplicateRelay called twice (internal/syncer/syncer.go:~548 and again inside CanReplicateRelay at ~632) Minor redundancy — CanReplicateRelay already calls SupportsReplicateRelay. Not wrong, just inefficient readability-wise.

7. closeOnce in the strategy is defensive but unused externally (internal/strategy/replicate/replicate.go:83-104) gitproto.LimitPackReader doesn't wrap in a double-close-safe reader? Worth a quick check whether the _ = packReader.Close() in both the success and error paths + defer pack.Close() in PushPack implementations is the actual duplication hazard. If so, a one-line comment explaining why closeOnce exists would save future readers.

8. Usage string duplication (cmd/git-sync/main.go:~410) The huge fmt.Sprintf with every subcommand's flags is now ~doubled in length. Not new to this PR, but now's a good time to split it into per-subcommand helpers or a usageFor(name) map.

9. Replicate relay vs. --prune delete flow Deletes go through PushCommands without a pack. That's correct, but there's no test covering replicate --prune end-to-end through the syncer (only planner). Worth adding one to catch regressions where pushPlans ordering or relayPlans filtering drifts.

Nice touches

  • Test server now has a reusable rewriteReceivePackAdvertisement helper — cleaner than the previous inline capability mutation.
  • operationModeString defaulting empty → "sync" in both bridges is consistent.
  • replicate correctly rejects --force at session construction with a clear "use sync instead" message; error wrapping throughout runReplicate consistently steers users back to sync on failure.
  • Planner's PlanReplicationRef reasons ("replicate overwrite" vs "replicate tag overwrite") are clear for debugging.

Suggested follow-ups (not blockers)

  • CHANGELOG entry for TransferMode rename.
  • Document the "replicate fails → retry with sync" semantics in docs/architecture.md — the code implements it but the doc only hints at it.

Want me to apply the small mechanical fixes (dead branch in defaultOperationMode, dryRun param cleanup, managed map cloning)?

b5fdb49Drain go-git upload-pack double-NAK before sideband demux Root cause: go-git's v6 upload-pack server (plumbing/transport/upload_pack.go) emits two NAK pktlines when the client sends haves but none are reachable from the wants. The first NAK is written via ServerResponse.Encode for the "if len(haves) > 0" branch (empty ACKs => NAK), the second via the "no common ack found" branch. The response looks like: 0008NAK\n 0008NAK\n 0009\x01PACK... (sideband channel 1) go-git's ServerResponse.Decode returns after consuming the first NAK, so the second NAK sits in front of the sideband-wrapped pack. The sideband demuxer then reads "NAK\n" as a frame and fails with "unknown channel NAK" (channel byte 'N' = 0x4e) because valid channels are 0x01/0x02/0x03. This reliably hits any fetch where the target's advertised ref is not an ancestor of the source's want - exactly the rewind/overwrite scenario replicate mode is designed for. The same bug affected sync --force via the materialized strategy, but was masked in existing tests because multi-ref scenarios happened to include at least one have whose hash was reachable from a want, producing an ACK and skipping the two-NAK branch. Fix: wrap the post-Decode reader in bufio.Reader and drain any extra "0008NAK\n" pktlines before handing off to the sideband demuxer. Applied to both fetchToStoreV1 (materialized path) and fetchPackV1 (relay path). A short stream that cannot satisfy the 8-byte peek is treated as "no more NAKs" so the downstream reader surfaces the real read error. Also reverts the V1->V2 workaround in TestRun_IntegrationReplicateOverwrites- DivergentBranch now that V1 handles this scenario correctly, and adds TestFetchPackV1DrainsSecondNAK asserting the drainer's behavior directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: a8de9a2a91c3+88/-5
1a477f8Let replicate relay against targets advertising no-thin 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> Entire-Checkpoint: e0212dc3cfb8+68/-16
6a219a1Wire max/batch pack byte flags into sync and replicate Replicate against a fresh target for a repo the size of linux currently fails with "decode report-status: invalid pkt-len found: short pkt-line 4" because the receive-pack POST body hits a server limit (or times out) and the target closes the connection before writing a report-status. The fix is batching, which the bootstrap strategy already supports and which replicate's bootstrap-fallback already plumbs to the strategy via Config.BatchMaxPackBytes -- but the flags were only registered on the bootstrap subcommand. Replicate and sync silently ignored them. Changes: - cmd/git-sync/main.go runSyncLike: register --max-pack-bytes and --batch-max-pack-bytes. Update the usage string for sync, replicate, and plan accordingly. - pkg/gitsync/unstable/client.go buildSyncConfig: forward MaxPackBytes and BatchMaxPackBytes from AdvancedOptions to syncer.Config. The fields already existed on AdvancedOptions; only bootstrap was using them. - internal/syncer/integration_test.go: add TestRun_Integration- ReplicateBootstrapBatchesWhenConfigured exercising a replicate call with BatchMaxPackBytes set, asserting it reaches the batched bootstrap path (batching=true, batch_count>=2) and leaves source and target heads matching. This guards the plumbing; regressing the forwarding would silently reintroduce the "flag has no effect" bug. - CHANGELOG.md: document the added flags and the unstable buildSyncConfig forwarding. Typical usage for a large initial replicate push: git-sync replicate \ --batch-max-pack-bytes 268435456 \ --max-pack-bytes 10737418240 \ ... splits the push into ~256 MiB batches, each of which the receive-pack server processes and acknowledges before the next starts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 08a1fb563aa3+54/-1
3b98784Stream sideband progress to stderr when -v is set Long replicate and sync runs against large repos were silent between bootstrap batch checkpoints because both the source upload-pack and the target receive-pack sideband progress channels were being discarded. Changes in internal/gitproto: - push.go: sendReceivePack now takes a verbose flag and wires the sideband demuxer's Progress field to stderr (prefixed "target: ") when set. Added a prefixedLineWriter that splits on both '\n' and '\r' so in-place git progress updates ("Resolving deltas: 12%\r") remain readable when prefixed. progressSink returns nil for non-verbose so the demuxer allocates nothing on the hot path. - fetch.go / refs.go: RefService gains a Verbose field. When true, fetchToStoreV1/V2, fetchPackV1/V2, and buildV1UploadPackBody stop asking the source to suppress progress (drop the "no-progress" upload-request capability and the "no-progress" v2 fetch arg) and wire the sideband demuxer's Progress to stderr (prefixed "source: "). - syncer.go: newSession propagates cfg.Verbose to sourceService.Verbose right after constructing it. Target-side verbose already flowed through gitproto.NewPusher. New tests: - TestPrefixedLineWriter covers line splitting on '\n' and '\r', mid-line writes that don't emit a trailing prefix, and empty writes. - TestProgressSinkNilWhenNotVerbose locks in the "no allocation when quiet" contract. Existing private fetch tests updated to pass the new verbose parameter. Commit-graph fetches (FetchCommitGraph) pass verbose=false because they are short and not user-facing. With -v, a replicate run now looks like: source: Enumerating objects: 120000, done. source: Counting objects: 100% (120000/120000), done. source: Compressing objects: 37% (44400/120000) ... target: Resolving deltas: 58% (69600/120000) target: Updating references: 100% (61/61), done. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: cdd8c35ae583+178/-41
8e4c21cReplace probe-based bootstrap checkpoint planning with estimate The previous checkpoint planner did full FetchPack round-trips per probe candidate to measure actual pack sizes, then binary-searched for the boundary that fit under --batch-max-pack-bytes. For linux/master (75k commits) this took 13+ fetch-and-discard cycles — downloading gigabytes of throwaway data and burning minutes — before any real push started. The precision was rarely needed: the adaptive retry and resume mechanisms already handle batches that turn out too large. Replace with estimate-based planning: 1. Fetch the commit graph (tree:0 filter, one round-trip — unchanged). 2. Walk the first-parent chain to get commit count (unchanged). 3. Estimate total pack size as chainLen × 8 KiB/commit. 4. Divide into ceil(estimated / batchMaxPack) evenly-spaced checkpoints. 5. Done. No probe fetches. For linux at 1 GiB batch limit: planning goes from ~4 minutes / 13 fetches to ~20 seconds / 1 fetch (just the commit graph). The estimate is intentionally conservative (8 KiB vs the old 4 KiB) so it errs toward more batches rather than fewer. If a batch still exceeds the target's limit, the push fails for that batch and bootstrap resume (via temp refs) ensures already-pushed batches aren't re-sent on the next run. Deleted ~535 lines of probe infrastructure: - checkpointPlanner struct and all methods - fetchPackForProbe, probeKey, probeResult, probeBounds - initialCheckpointSpan, adaptiveNextProbeSpan - shouldProbeTipFirst, shouldSelectTipWithoutProbe - nextCheckpointProbeCandidate, searchCheckpointUnderLimit - prefetchedPacks field on plannedBatch and its lookup in packReaderForCheckpoint Added: - estimateBatchCount (ceil division with 8 KiB heuristic) - evenCheckpoints (evenly-spaced placement along the chain) - TestEstimateBatchCount and TestEvenCheckpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: d0974f7b54b6+117/-652
d851ee0Plan trunk first in batched bootstrap to cut per-branch graph fetches Before: each branch's checkpoint planning fetched the full commit graph from the source independently. For a repo with 152 feature branches that all descend from main, that meant ~152 full-history commit-graph fetches and ~152 independent first-parent walks, each traversing the same shared history. On a linux-sized source this was ~40 s of wall time during planning alone, before any object bytes moved. Now the trunk is identified up front via the source's HEAD symref, planned first, and its commit reachability is captured as a hash set. Each subsequent branch's graph fetch passes trunk's tip as a have so the source returns only the divergence, and the first-parent walk terminates when it enters trunk's ancestry instead of walking back to root. Pieces: - gitproto: expose HEAD symref on RefService.HeadTarget. v1 reads it from the symref capability; v2 adds "symrefs" to ls-refs and a "ref-prefix HEAD" argument so HEAD is actually returned (otherwise ref-prefix refs/heads/ filters it out and HeadTarget stays empty). HEAD itself is consumed for the symref target and not surfaced in the ref slice, matching v1 behavior where symbolic refs are filtered downstream. - gitproto: FetchCommitGraph takes an optional []plumbing.Hash of haves, forwarded into the v2 fetch command. Nil/empty preserves prior behavior. - planner: add FirstParentChainStoppingAt that terminates the walk when a commit is in a caller-supplied stop set. FirstParentChain becomes a thin wrapper with nil stop set for existing callers. - bootstrap: orderTrunkFirst reorders DesiredRefs so the HEAD branch is planned first; collectCommitHashes extracts just the commit hash set (~8 B per commit, ~11 MB for linux) from trunk's graph store before it is GC'd. Subsequent branches plan with trunk's tip as a have and trunk's ancestry as a stop set. Emits one of three diagnostic log lines at planning time so operators can see whether the fast path fired: trunk selected, trunk unset (no HEAD advertised), or trunk unset (HEAD filtered by --branch/--map). Subsumed branches (tip in trunk's ancestry) emit a single-checkpoint batch today — the pack push for those is a follow-up optimization. Empty-chain edge case: when stop-at subsumes the branch tip, FirstParentChainStoppingAt returns nil and planCheckpointsFromChain falls back to [tip] so the existing execute-phase logic still creates the target ref. Falls back to the original per-branch behavior when HeadTarget is empty or when the trunk ref is filtered out of the desired set, so sources without symref advertisement and scoped runs (--branch, --map) are not regressed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 32bf11bd8793+393/-52