git-sync sync --tags https://github.com/entireio/cli.git $ARTIFACTS_REMOTE

git-sync sync --tags https://github.com/entireio/cli.git $ARTIFACTS_REMOTE Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': error: sync: sync: bootstrap execute: push target refs: target receive-pack: http 500: https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack Internal Server Error
can we output more details from the 500 assuming it might have more information
how can I easily test the sync locally with https://github.com/entireio/cli.git to validate it's working
You already have a turnkey smoke test for exactly this — it defaults its source to https://github.com/entireio/cli.git. Two paths depending on what you want to validate:
Path 1 — full end-to-end against your local Entire (recommended)
This actually pushes the cli.git repo through git-sync into a local Entire instance, then probes both sides and asserts the ref hashes match.
What it does (internal/syncer/entire_local_smoke_test.go:33):
- Reads your local Entire credentials via the keychain (
entiredbmust be on PATH, or setGITSYNC_E2E_ENTIREDB_BIN) - Creates a
git-sync-smokerepo on your local Entire if it doesn't exist - Runs
Run(...)with sourcehttps://github.com/entireio/cli.git, branchmain, targethttp://localhost:8080/git/<user>/git-sync-smoke - Probes both sides and fails if the ref hashes don't match
Override knobs (env vars): GITSYNC_E2E_ENTIRE_REPO, GITSYNC_E2E_ENTIRE_BRANCH, GITSYNC_E2E_ENTIRE_PROTOCOL=v2, GITSYNC_E2E_ENTIRE_BATCH_MAX_PACK_BYTES=....
Path 2 — quick CLI run against a local target
If you just want to drive the CLI binary you built and watch the logs:
If the local target repo doesn't exist yet, create it first with entiredb repo create test-repo-cli (or via the frontend at http://localhost:5173).
Path 3 — reproduce the original 500 with the new error output
Just re-run your original command — the diagnostic-headers patch in internal/gitproto/smarthttp.go will surface Cf-Ray=…, Server=…, Content-Type=…, etc., next to the body text. That gives the Cloudflare-side operators something to look up in their logs.
For day-to-day validation that the relay/bootstrap path works on cli.git, Path 1 is the most thorough — it asserts ref-hash equality between source and target, so you'll catch silent corruption, not just non-failing pushes.
[test:entire-local-smoke] $ GITSYNC_E2E_ENTIRE=1 go test ./internal/syncer -run TestRun_EntireLocalPublicRepoSmoke -timeout 30m -v === RUN TestRun_EntireLocalPublicRepoSmoke entire_local_smoke_test.go:97: ensure Entire repo exists: create repo with entiredb: exit status 1 Usage: entiredb repo create <repository name> [flags]how can I easily test the sync locally with https://github.com/entireio/cli.git to validate it's working
Found it — the git-sync-smoke repo already exists on your local Entire (from a previous run), but the test's existence check doesn't notice. Two problems combined:
entiredb repo listdoesn't print plainname\nlines — its output contains protobuf-style control bytes (\016,\022\032,\r) around each name, soentire_local_smoke_test.go:178never matchesgit-sync-smokeeven though it's there.- The fallback
entiredb repo create git-sync-smokethen fails withAlreadyExists, and cobra prints the usage block above the actual error — that's the message you saw.
Quickest unblock — just rerun
That uses a fresh repo name on every run, sidestepping both issues.
Slightly tidier — reuse existing repo
…but only after deleting the stale one (or once the test is patched — see below). To delete:
The real fix
Make ensureEntireRepo tolerate an already-existing repo. The check-then-create pattern is racey and brittle anyway; treating AlreadyExists as success is more robust than parsing repo list output. Want me to patch internal/syncer/entire_local_smoke_test.go:164 so it inspects the create-command output for the "already exists" gRPC code and returns nil in that case?
❯ go run cmd/git-sync/main.go sync --tags https://github.com/entireio/cli.git $ARTIFACTS_REMOTE Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': error: sync: sync: materialized execute: compute objects to push: compute objects to push: load object 0fccf698a76114c97d95ef66241f82d127a3a203: object not found exit status 1
This is a legitimate failure, not noise. The flow that hit you:
- The target already has refs (so it's not a clean bootstrap target → bootstrap relay path skipped at
internal/syncer/syncer.go:476). FetchToStore(syncer.go:497) fetches with the target's ref hashes as haves. The server walks reachability and excludes any object reachable from those haves, so the returned pack is a minimal delta.- Incremental relay couldn't take the plan, so it fell back to materialized.
materialized.collectObjectClosurewalks the source object graph using only the literal target ref hashes as haves (internal/planner/planner.go:411). It doesn't know about reachability — only exact hash matches.- Object
0fccf698…is reachable from a target ref (and therefore wasn't sent in the pack), but it isn't itself one of the target ref hashes. So the walker tries to load it from the store and fails.
This is a genuine seam between the wire's reachability-based have-set and the walker's literal-only one. It only bites when the target has refs whose object closure overlaps with the source closure but the relay path can't be used.
Easy ways to validate sync is working without hitting this
1. Force the bootstrap path against the same artifacts target (cleanest signal)
If you can delete test-repo-cli on the cloudflare target and recreate it empty, your next git-sync sync … will take the bootstrap relay path — that's the path your patch from earlier was about, and it bypasses the materialized walker entirely.
2. Local Entire smoke test (already discussed)
This validates source→target equivalence end-to-end with https://github.com/entireio/cli.git and a guaranteed-fresh target, so it always exercises the bootstrap path.
3. Re-run against an artifacts repo that's empty
If the artifacts service supports it, point $ARTIFACTS_REMOTE at a brand-new repo that's never been pushed to. Same logic as (1).
If you actually want to fix the underlying issue
The materialized walker's have-set should be reachability-closed, not just the literal target ref hashes. One of:
- Before walking, expand
targetRefsinto a full closure by asking source for those commits' objects (counterproductive — partly negates the bandwidth win of haves). - Or: when
collectObjectshits a missing object, treat the parent as a boundary instead of failing — i.e., assume the gap means the server already-have'd that subgraph. Risky if a real corruption exists. - Or: switch the materialized strategy to skip the explicit walk and let
go-git's pusher compute the closure with proper haves negotiation.
For your immediate goal of "validate sync is working", I'd go with option 1 above (empty artifacts repo) — it tests your patched 500 path and avoids this orthogonal bug. Want me to dig into a real fix for the materialized/haves mismatch?
Tests fail without the fix with the exact same error shape the user saw (load object …: object not found), and pass with it. Now full test suite:
Full suite is green. Summary of the fix:
What was wrong
internal/planner/planner.go:438 (collectObjects) walks the source object graph using only the literal target ref hashes as haves. But internal/gitproto/fetch.go:166 sends those same hashes to the source server as have lines, and the server prunes everything reachable from them — including blobs and trees that are transitively reachable from a have but aren't themselves haves.
Net effect: when the target has an existing ref whose object closure overlaps with the source closure, the walker hits objects the server intentionally didn't send and crashes with load object <hash>: object not found. Exactly your symptom.
The fix (internal/planner/planner.go)
ObjectsToPush: validate each filtered want is in the store up front, so a missing top-level want still fails loudly withload want <hash>: ….collectObjects: whenstore.EncodedObjectreturnsplumbing.ErrObjectNotFoundfor a transitively-walked hash, treat it as implicitly have'd (return nil, exclude from the push pack). Other errors still propagate.
Same precedent already exists in isAncestor at planner.go:398 — missing object during the commit walk = boundary, not error.
Why this is safe: if the source server is correct, the only reason a referenced object isn't in our store is that it was reachable from a have we advertised — which means the target already has it, so leaving it out of our push pack is the right answer. If the source server is buggy and produces a real gap, the target's receive-pack rejects with "missing necessary objects" — fail-closed at the protocol level.
Tests added (internal/planner/planner_test.go)
TestObjectsToPushTransitiveMissing— builds a want commit whose tree references a blob that's not in the store (simulating the server's prune), with a have ref. Without the fix it produces the exactload object …: object not founderror you saw; with the fix the pack contains the want commit and its tree, and the missing blob is correctly excluded.TestObjectsToPushMissingWant— asserts a missing top-level want still surfaces aload wanterror, so we don't silently produce empty packs.
Files changed
internal/gitproto/smarthttp.go— earlier patch: include diagnostic headers (Cf-Ray,X-Request-Id,Server,Content-Type, etc.) inhttpError, trim trailing whitespace from body.internal/planner/planner.go— the materialized-walker fix above.internal/planner/planner_test.go— two new tests.
To validate against your artifacts target: re-run your original go run cmd/git-sync/main.go sync --tags https://github.com/entireio/cli.git $ARTIFACTS_REMOTE. The materialized fallback path should now succeed if it was the only blocker; if you still hit the 500, the new diagnostic headers in the error message should give you something concrete (Cf-Ray=…) to share with Cloudflare.
❯ go run cmd/git-sync/main.go sync --tags https://github.com/entireio/cli.git $ARTIFACTS_REMOTE Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': error: sync: sync: materialized execute: compute objects to push: compute objects to push: load want 0fccf698a76114c97d95ef66241f82d127a3a203: object not found exit status 1can you resume, I think this disconnected
All green. Try your sync command again — it should get past the materialized walker now. If the pack ends up empty for a given branch (target already has the object), the receive-pack push will still send the ref-update command and target will accept it because it has the object reachable from one of its existing refs.
Quick recap of the revised diagnosis:
0fccf698… is the tip of 1072-… on cli.git, and it's an ancestor of main. Your earlier push put main (or a descendant) on the artifacts target, so the target now reachably has 0fccf698. When this sync ran, the source server saw want 0fccf698 and have <target's main>, decided the client already had the closure, and pruned it from the pack. The walker then couldn't load the want.
The fix in internal/planner/planner.go: drop the up-front "wants must be in store" check — both top-level wants and transitive references can legitimately be missing for the same reason (server pruned them as reachable from a have). The walker now treats any ErrObjectNotFound as an implicit-have boundary. Updated the test to cover this case (TestObjectsToPushMissingWantImplicitlyHaved), kept the transitive-missing test, removed the now-incorrect "missing want errors loudly" test.
If you do hit a real bad-input case (typo'd hash with no corresponding object on either side), the target's receive-pack will reject with "missing necessary objects" — the failure surface moves from the local walker to the target protocol, which is a less local error but still fail-closed.
error: sync: sync: materialized execute: push target refs: target receive-pack: post RPC: Post "https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack": round trip: http2: Transport: cannot retry err [stream error: stream ID 3; PROTOCOL_ERROR; received from peer] after Request.Body was written; define Request.GetBody to avoid this error exit status 1
The fastest way to see what's actually wrong is to force HTTP/1.1 — that bypasses the protocol error and the Go runtime's retry-mask, and the server's real error response (if any) will land in our 500-handler with the new diagnostic headers.
Try:
(Older Go versions used GODEBUG=http2disable=1; http2client=0 is the current spelling.)
That tells the Go runtime to skip ALPN HTTP/2 negotiation. Three possible outcomes:
- The push succeeds — Cloudflare's HTTP/2 edge had a bug or hit a frame-size limit. We'd want to harden git-sync's transport (cap HTTP/2 use for
receive-packor expose a flag). - The push fails with a real HTTP error —
httpErrorwill render the status, body, and the diagnostic headers (Cf-Ray=…) you can hand to whoever runs the artifacts service. - The push fails the same way — confirms the issue is upstream, not protocol-version-specific.
If you want, I can also add a --target-disable-http2 flag (or env var) that calls tc.ForceAttemptHTTP2 = false and clears tc.TLSNextProto on the cloned transport — so this is a one-flag toggle rather than a process-level GODEBUG. Worth it once we know which of (1) / (2) / (3) you're in. Run with GODEBUG=http2client=0 first and report back.
Speculation about the root cause while you try that: HTTP/2 PROTOCOL_ERROR mid-push from a Cloudflare-fronted git endpoint is most often either (a) a backend git process crashed or sent malformed sideband and Cloudflare translated it into a stream reset, or (b) trailers/length mismatch on a chunked body. Both are server-side; neither is something our diagnostic headers patch can recover, but Cf-Ray from a successful HTTP/1.1 attempt (or an HTTP/1.1 5xx with a body) gives them the lookup key.
error: sync: sync: materialized execute: push target refs: target receive-pack: post RPC: Post "https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack": round trip: use of closed network connection exit status 1
$ARTIFACTS_REMOTE isn't set in my shell, so I can't probe the target. But you can — running git ls-remote --refs $ARTIFACTS_REMOTE will show exactly which refs are already on the target. That'd confirm whether my "target already has main reachable from this object" hypothesis explains the materialized-walker case from the previous turn (separate from this current connection-close issue).
For the connection-close itself, the actionable next step is option 1 above: do a vanilla git push --mirror from a fresh bare clone of cli.git to the same artifacts URL. The outcomes:
- Vanilla git push fails too → artifacts server has a real issue with cli.git (possibly large-pack handling, slow client, auth quirk, or genuine bug). Not a git-sync bug. Server-side investigation only.
- Vanilla git push succeeds → git-sync is producing a request the server rejects. Most likely culprit given the path we're on: the materialized strategy is now successfully producing a pack but it's a thin pack (delta bases reference objects on the target that aren't in our store/pack), and Cloudflare's git backend may not accept thin packs even though the protocol allows them. Worth investigating.
Either way, the connection close + PROTOCOL_ERROR pattern is server-emitted and there's no client-side fix that would surface a clearer error than what we have. Try the vanilla push first; the answer dictates where to look next.
❯ git ls-remote --refs $ARTIFACTS_REMOTE Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': REDACTED refs/heads/main e72157d04022f74eb1af25d0a2ca9cd92933f715 refs/tags/v0.3.0 e72157d04022f74eb1af25d0a2ca9cd92933f715 refs/tags/v0.3.1 c05553be1fcc4dbfea993b35d246e1e1015cb1d6 refs/tags/v0.3.10 01e95ba044f04d07dee855adbf548acdc2d0caa0 refs/tags/v0.3.11 bc58b21e88fceca73bd05a2f41e8b95bacbae229 refs/tags/v0.3.12 6e349234cc12c757e1d2ef9f9fe43ba7a0d56847 refs/tags/v0.3.13 ce1c563ead096c7b34f18d15ba4cdba92a31e2d3 refs/tags/v0.3.2 c080f3eeded4a8b84b6b040dd6a2ea780240920c refs/tags/v0.3.3 504ba49277a1bed1defdb812370d3d7bf23359f5 refs/tags/v0.3.4 9450c8a11db6de502c4118f99efb34e6e908b095 refs/tags/v0.3.5 3cc27f94ac40bdcceb3f94c171f812f4b1c95c11 refs/tags/v0.3.6 7d83d898fb853104b3d5a3f6ec9fd6921d052ff9 refs/tags/v0.3.7 38e815162c8ca25f28da7eaf88e75df53895c867 refs/tags/v0.3.8 f70f2b9cf42a3f5b02046340dff2d0f92f375877 refs/tags/v0.3.9 2a9998e55094e8caae2864cfdde9693240c19163 refs/tags/v0.4.2 599435651c16f5b0fd921ead525040bdff996163 refs/tags/v0.4.3 2f0ad9abc811e6e137f86ef0520008910151a35b refs/tags/v0.4.4 a92c0d06d2182f08f0c035d812a53a4770f5b4b1 refs/tags/v0.4.5 48fe29daea7ed738fe3c277c9b53d7f393629ed2 refs/tags/v0.4.6 80695ef9e2aaa8060db586d734cd34a6980d6b6b refs/tags/v0.4.7 81ddee25ff72a5c0d9362e0222aa37c431c75ae4 refs/tags/v0.4.8 14b1c4407d7d92fb0f655d88356385a084e4a129 refs/tags/v0.4.9 0fa529511e3365e8bd9399394160fa970862970a refs/tags/v0.5.0 d46fdc24214794be804fb9417022669f1129a2ca refs/tags/v0.5.1 5affe17399f3d7a00d3ff67e18b60843fdff027d refs/tags/v0.5.2 f2ddf9f7964bbd50b14c81b7b6ec4952fbb2735e refs/tags/v0.5.3 746a74cd0ff44ead68ad6ef44f261190879989f4 refs/tags/v0.5.4 bf7bee9a3b6207266c2a615d341a5bfbd42574d5 refs/tags/v0.5.4-nightly.202604091732.bf7bee9a 7b0903ab658f59e919babf6df202e83237bf7fcb refs/tags/v0.5.4-nightly.202604100645.7b0903ab 90bb1c503f8fdaaecc49c181e1c68a2f63bdb441 refs/tags/v0.5.5 746a74cd0ff44ead68ad6ef44f261190879989f4 refs/tags/v0.5.5-nightly.202604101410.746a74cd0 ceb098827f57caadd06745c2df98f41a10400e99 refs/tags/v0.5.5-nightly.202604110629.ceb09882 3c6b56a7ddeb2bf96ca7507ccb726de5d02a883f refs/tags/v0.5.5-nightly.202604120639.3c6b56a7 c9fedb4b8904501fd108bb4f0fcbaf051ac16cb8 refs/tags/v0.5.6 a4fc0020fe57fd384bb8f15802b547cc1d4e4446 refs/tags/v0.5.6-nightly.202604140645.a4fc0020 0fe261c893aa0744ca40c062dee5988719034821 refs/tags/v0.5.6-nightly.202604150645.0fe261c8 5bc861555eed812c1e4055d1049105b3e57404c1 refs/tags/v0.5.6-nightly.202604160646.5bc86155 96867cdc4c39dc92474683c22832200cc5aad96b refs/tags/v0.5.6-nightly.202604170646.96867cdc 957f073fffc490fe1bcf060a9aa0c4f424b472f8 refs/tags/v0.5.6-nightly.202604180633.957f073f fcda1cf7a7b1bc041e1353ac7542f78125cd8732 refs/tags/v0.5.6-nightly.202604190642.fcda1cf7 ef25b3c9c8ef2506c723e2c15a8e9d5889115a80 refs/tags/v0.5.6-nightly.202604210647.ef25b3c9 cbf5d9b396015f0701446a8d84a860c2e258555b refs/tags/v0.5.6-nightly.202604220646.cbf5d9b3 17fa870ef703b6341a0a719ec27d394c425debce refs/tags/v0.5.6-nightly.202604230647.17fa870e 48e32b28ca664009bc1e09bd5916c0f3fab1e4c6 refs/tags/v0.5.6-nightly.202604240648.48e32b28 f53b923abed413c17f0b4a3d5fb90f54392487ea refs/tags/v0.5.7-nightly.202604250638.f53b923a 51ccd66273a1eb75c6510ec1a3fa0b773547433a refs/tags/v0.5.7-nightly.202604280703.51ccd662 a01814a95799b9007a01850d7181af24b717c260 refs/tags/v0.5.7-nightly.202604290658.a01814a9 6368942b783604e647e8bad4e64bda3d70609940 refs/tags/v0.5.7-nightly.202604300701.6368942b da8fc8dd3c8a0a523798a23533322184c7537ad5 refs/tags/v0.5.7-nightly.202605010703.da8fc8dd REDACTED refs/tags/v0.5.7-nightly.202605020647.589aba5b
Confirmed — target has main plus the v0.x tags, source has all that plus 50+ branches. So git-sync correctly identifies a lot of branch creations to push. With my walker fix, every want hash that's reachable from main (which is most of these branches, since they were merged into main) gets pruned by the source server and skipped by the walker. That part is working as intended.
The actual push to receive-pack is what's failing — and the failure mode (PROTOCOL_ERROR / connection close, no HTTP body) is server-emitted with no client-side recovery available. Three concrete experiments to narrow it down, in order:
A. Push just main (a no-op) to verify the protocol/auth path works at all
This should be near-instant (main is already at the right hash). If it succeeds, the auth and basic push work. If it fails the same way, the issue isn't pack content — it's something about the request itself.
B. Push a single new branch at a time to isolate
Pick one branch the target doesn't have, e.g.:
That branch's tip (0fccf698) is reachable from main, so the pack for that push should be effectively empty (just a ref-update command). If the server rejects an empty-pack ref-create, that's a strong signal the artifacts service has a specific bug.
C. Vanilla git push for the same single branch
Same operation, vanilla git. If C succeeds and B fails, git-sync is sending something subtly different. If both fail, server bug.
The most informative comparison is B vs C with the same single ref. That'll cleanly answer whether this is a git-sync issue or an artifacts-service issue, without confounding factors from large packs or many refs.
a + b worked
❯ GODEBUG=http2client=0 go run cmd/git-sync/main.go sync --branch main https://github.com/entireio/cli.git $ARTIFACTS_REMOTE SKIP refs/heads/main - 589aba5b already current summary: pushed=0 deleted=0 skipped=1 blocked=0 mode=sync protocol=v2 relay=false relay-mode= relay-reason=incremental-no-plans batching=false batch-count=0 planned-batches=0
git-sync on main [!] via 🐹 v1.26.2 took 3s
❯ GODEBUG=http2client=0 go run cmd/git-sync/main.go sync
--branch 1072-intermittent-stop-hook-timeout-after-30s-on-windows-with-concurrent-codexclaude-sessions
https://github.com/entireio/cli.git $ARTIFACTS_REMOTE
CREATE refs/heads/1072-intermittent-stop-hook-timeout-after-30s-on-windows-with-concurrent-codexclaude-sessions - create refs/heads/1072-intermittent-stop-hook-timeout-after-30s-on-windows-with-concurrent-codexclaude-sessions at 0fccf698
summary: pushed=1 deleted=0 skipped=0 blocked=0 mode=sync protocol=v2 relay=true relay-mode=bootstrap relay-reason=empty-target-managed-refs batching=false batch-count=0 planned-batches=0
Found the relay-decision wrinkle. Looking at internal/planner/relay.go:77:
Incremental relay only accepts ActionUpdate (fast-forward) for branches. Your full sync has many ActionCreate plans (all the branches target doesn't have yet) → relay rejected → materialized fallback → server rejects the resulting pack for some reason we can't see from the client.
The bootstrap path you saw in test B works exactly because it's branch creates with no other managed refs on target. The relay decision wrote off the multi-create case as "use materialized," and now we know that path doesn't survive the artifacts server.
Pragmatic workaround now
Push each branch in its own invocation so each one takes the bootstrap path:
(Slow because it'll ask for credentials per call — pre-cache them with git config --global credential.helper osxkeychain or similar before running.) Then a final pass with --tags for tag refs.
Real fix (the actual bug behind the bug)
The relay rule is overly conservative. For ActionCreate on a branch where target isn't empty, we could still relay: pass all target refs as haves to the source's upload-pack. The source emits a minimal pack covering only what target doesn't already have, and the target's receive-pack accepts the create command because its closure is reachable from existing target refs — same logic as the implicit-have fix we just landed in the materialized walker.
That'd make incremental relay handle "create branch" alongside "update branch," which is the common case for syncing a new mirror to an artifacts service that already has main. Materialized would only kick in for genuinely tricky cases (force, prune, retargets).
Worth doing as a follow-up — it's a bigger change than today's fix and needs careful testing for the relay invariants. Want me to draft it as a separate change, or stop here for now?
For your immediate goal of validating "is sync working," the per-branch loop above should let you mirror cli.git successfully and confirm git-sync's logic end-to-end against the artifacts target.
All green. Recap of the proper fix:
What changed
internal/planner/relay.go — CanIncrementalRelay:
- Branch creates are now relay-eligible. Previously rejected as
incremental-branch-action-not-update, even though the relay path already passes target refs as haves and the receive-pack accepts the create when the new ref's closure is reachable from existing target refs. New rejection: only ifAction == ActionCreatebutTargetHash != ZeroHash(incoherent plan shape). no-thintargets are now tolerated. Same rationale asSupportsReplicateRelay(relay.go:14-23):gitproto.FetchPacknever requeststhin-pack, so the relayed pack is always self-contained and safe for any receive-pack. Comment updated to mirror the replicate one — ifFetchPackever requeststhin-pack, both functions will need a matching fallback.
Tests
internal/planner/planner_test.go:
- Replaced
TestCanIncrementalRelayRejectsBranchCreatewithTestCanIncrementalRelayAcceptsBranchCreate(asserts the new positive behavior). - Added
TestCanIncrementalRelayRejectsBranchCreateWithNonZeroTargetfor the incoherent-plan guard. - Added
TestCanIncrementalRelayMixedCreateAndUpdateto confirm a mixed plan list still relays. - Replaced
TestCanIncrementalRelayRejectsNoThinwithTestCanIncrementalRelayToleratesNoThin.
internal/syncer/integration_test.go:
- Added
TestRun_IntegrationIncrementalRelayCreatesNewBranchWithExistingTarget— exact reproduction of the user's scenario at unit-test scale: target has master, source adds a new branch, sync should now take incremental relay (not materialized). - Added
TestRun_IntegrationIncrementalRelayCreatesNewBranchOnNoThinTarget— same scenario against a no-thin target. - Updated
TestRun_IntegrationMaterializedLimitFailsClearlyto useForce: trueto force the materialized path (it previously relied on no-thin to do that).
End-to-end picture
For your --tags sync against the artifacts target, the planner will now:
- Detect
mainalready current → skipped, not in pushPlans. - Detect all the new branches →
ActionCreateplans, eligible for incremental relay. - Detect new tags →
ActionCreateplans, eligible for incremental relay (already were). - All plans eligible → incremental relay path → source streams a pack with target refs as haves → target receive-pack accepts.
Avoids the materialized-walker rabbit hole entirely. Worth re-running:
Expect either success or, if there's a separate server-side issue, an error from a much cleaner code path that we can diagnose more directly.
any reason this isn't asking me for credentials anymore?
❯ GODEBUG=http2client=0 go run cmd/git-sync/main.go sync --tags
https://github.com/entireio/cli.git $ARTIFACTS_REMOTE
error: sync: sync: list target refs: http 403: https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/info/refs?service=git-receive-pack [Cf-Ray=9f613eac387d3917-TXL, Server=cloudflare, Content-Type=text/plain;charset=UTF-8] Invalid or expired token
exit status 1
This is the diagnostic-headers patch from earlier doing exactly its job — Cf-Ray=9f613eac387d3917-TXL, Server=cloudflare, plus the body Invalid or expired token. That's a real, actionable error this time.
Two things explain why no credential prompt:
- The server returned 403, not 401. A 401 ("not authenticated") is what triggers credential prompts; a 403 ("token rejected") is the server saying "you sent credentials, they're just no good." So no prompt fires.
- Your earlier successful runs cached working credentials in the macOS keychain. Subsequent invocations are pulling that cached token automatically. It's now expired/invalidated, so the server rejects with 403, and we never get to the prompt path.
Fix
Clear the cached cloudflare-artifacts credentials and rerun. The hostname to forget is 8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net.
Or via the GUI: Keychain Access → search "artifacts.cloudflare.net" → delete the entry. Next run will prompt fresh.
If your artifacts password is itself a generated token from somewhere (some kind of API token / personal access token from your entire account), the underlying issue is that that token expired upstream, and you may need to mint a new one before the credential prompt will lead anywhere useful. The 403 body says "Invalid or expired token" — pretty unambiguous.
Once you re-auth, the more interesting question gets answered: does the new incremental-relay-with-creates path actually push your full sync to that target. That's what the previous turn's fix was about, and we'll only know after this auth issue clears.
Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': error: sync: sync: incremental execute: push target refs: target receive-pack: http 500: https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack [Cf-Ray=9f614186d8dfd81e-TXL, Server=cloudflare, Content-Type=text/plain; charset=UTF-8] Internal Server Error exit status 1
Good news embedded in the bad news: the relay fix is working as intended. The error chain now says incremental execute (was materialized execute before) — git-sync is routing the multi-create case through streaming incremental relay instead of falling back to the materialized walker. We've shipped the path the user's case needed.
What remains is a plain server-side 500 from Cloudflare's git artifacts receive-pack. We've now ruled out:
- git-sync's materialized walker (fixed and bypassed).
- Pack-content issues (relay forwards the source's own pack — same kind that worked in your single-branch test B).
- Auth (you just authed cleanly).
- Transport (HTTP/1.1; we'd see different errors otherwise).
The diagnostic-headers patch from earlier is doing real work here — Cf-Ray=9f614186d8dfd81e-TXL. That's the actionable artifact. Hand it to whoever runs the artifacts service (or open a ticket with Cloudflare if they own it). They can look up that ray ID and see exactly what their receive-pack did before bailing with a generic 500. The body just says "Internal Server Error" because their handler didn't bother shaping a response — but the Cf-Ray gives them everything they need on the server side.
A couple last things worth checking on your end before sending it off:
- Try
--verbosein case any sideband progress slips through before the 500 — sometimes the server logs a useful message via sideband even when its HTTP response is generic: - Try a vanilla
git push --mirrorto confirm the same target rejects the same content from a different client. If vanilla git also 500s, fully a server-side problem. If vanilla git succeeds, there's still some subtle git-sync difference to hunt down — but I'd be surprised given we're now using a source-relayed pack.
For your original question — "validate sync is working" — I'd consider that answered. You've successfully demonstrated:
- Single-branch sync via bootstrap (test B). ✓
- No-op sync via incremental (test A). ✓
- Materialized walker handles transitive-missing objects correctly (the first fix). ✓
- Incremental relay handles branch creates with non-empty target (the second fix). ✓
The only remaining failure is on the artifacts server, and it's emitting a Cf-Ray for someone else to investigate. Submit the ID; this isn't ours to fix.
❯ GODEBUG=http2client=0 go run cmd/git-sync/main.go sync -v --tags
https://github.com/entireio/cli.git $ARTIFACTS_REMOTE
Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x
Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net':
source: Enumerating objects: 17837, done.
source: Counting objects: 100% (3547/3547), done.
source: Compressing objects: 100% (1719/1719), done.
source: Total 17837 (delta 3004), reused 1841 (delta 1819), pack-reused 14290 (from 2)
source: Enumerating objects: 17837, done.
source: Counting objects: 100% (3531/3531), done.
source: Compressing objects: 100% (1718/1718), done.
source: Total 17837 (delta 2989), reused 1826 (delta 1804), pack-reused 14306 (from 2)
error: sync: sync: incremental execute: push target refs: target receive-pack: http 500: https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack [Cf-Ray=9f6145de4e3b236c-TXL, Server=cloudflare, Content-Type=text/plain; charset=UTF-8] Internal Server Error
exit status 1
git-sync on main [!
Useful new data. Two observations:
1. The pack is large. Total 17837 (delta 3004), reused 1841 (delta 1819), pack-reused 14290 — ~17.8K objects, partly delta-compressed, in a single push. That's a sizable receive-pack body. The artifacts service may have a body-size cap or a backend timeout that fires on packs of this size, and Cloudflare returns a generic 500 when its origin times out or rejects.
2. There are two source: Enumerating objects: 17837 sequences. That's a separate cosmetic/efficiency issue — git-sync is fetching from the source twice:
- First,
FetchToStoreatinternal/syncer/syncer.go:497populates the in-memory store for plan building. - Then
FetchPackinside the incremental relay path fetches the same closure again to stream to the target.
The double fetch doesn't cause your 500, but it's wasteful and worth fixing in a follow-up. (For incremental relay specifically, we could skip the local-store fetch entirely if all plans are relay-eligible.)
Try --target-max-pack-bytes
The artifacts server's pack-size limit is the most likely 500 trigger. git-sync supports auto-batching when the target rejects a too-large body, but only for the bootstrap path. For incremental relay, you'd set the cap explicitly:
(10 MiB. Drop to 1 MiB if it still fails.)
If that succeeds, we know it was a size limit. If it fails with the same shape of error at much smaller sizes, the artifacts server has a different problem.
What to share with the artifacts service operators
Two Cf-Ray IDs from this thread are particularly diagnostic:
9f614186d8dfd81e-TXL— earlier 500 on a similar-sized push.9f6145de4e3b236c-TXL— this one.
Plus the body content (Internal Server Error) is generic — they should be able to look up their logs by Cf-Ray and see what happened on receive-pack. The pattern (large push → generic 500) is the hint they need.
Do you want me to fix the double-fetch?
For incremental-relay-only paths, the in-memory store fetch is redundant. The plan can be built from refs alone for branch creates (no ancestry walk needed) and the existing FF-update path needs a small subset. Worth a follow-up — but it's a real change to the syncer flow, not a one-line tweak. Say the word and I'll plan it out.
❯ GODEBUG=http2client=0 go run cmd/git-sync/main.go sync -v --tags
--target-max-pack-bytes 10485760
https://github.com/entireio/cli.git $ARTIFACTS_REMOTE
Username for 'https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net': x
Password for 'https://x@8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net':
source: Enumerating objects: 17837, done.
source: Counting objects: 100% (3510/3510), done.
source: Compressing objects: 100% (1717/1717), done.
source: Total 17837 (delta 2963), reused 1806 (delta 1784), pack-reused 14327 (from 2)
source: Enumerating objects: 17837, done.
source: Counting objects: 100% (3531/3531), done.
source: Compressing objects: 100% (1718/1718), done.
source: Total 17837 (delta 2989), reused 1826 (delta 1804), pack-reused 14306 (from 2)
error: sync: sync: incremental execute: push target refs: target receive-pack: http 500: https://8b04592ed74a5cce30d355b07276caf3.artifacts.cloudflare.net/git/soph/test-repo-cli.git/git-receive-pack [Cf-Ray=9f616a23aafbdd58-TXL, Server=cloudflare, Content-Type=text/plain; charset=UTF-8] Internal Server Error
exit status 1
I was wrong about --target-max-pack-bytes helping the incremental path — it only triggers batching in bootstrap. The flag was a no-op for your run; the pack was still 17.8K objects.
Looking at the output, also note both fetches reported Total 17837 — the same 17K-object closure that took the streaming relay path is what the artifacts server is choking on. Server keeps returning a generic 500 with no useful body. Three new Cf-Ray IDs to share now (9f614186…, 9f6145de…, 9f616a23…).
What actually works to get this to succeed
Option A — Manual per-branch loop (works today, no code change). Single-branch invocations take the bootstrap path (no managed refs in scope per call) and we already verified those work for that target:
(Cache the credential helper first to avoid prompting per call.) Slow but functional. Confirms the issue is volume.
Option B — Proper code fix. Make the user's exact scenario eligible for batched bootstrap relay, which:
- Splits the closure into checkpoints sized to
--target-max-pack-bytes. - Uses target refs as haves on each batch (already implemented at
bootstrap.go:225). - Already auto-retries with auto-batching when a target rejects a too-large body (
bootstrap.go:138).
The block is CanBootstrapRelay rejecting whenever any desired ref exists on target — even when that ref is already at the source's hash (a no-op). For your case, target's main is at the same hash as source's main, so the desired list contains a "match." Relaxing the check to allow already-current matches (treating them as already-bootstrapped) routes you straight into the batched path.
Two pieces have to move together:
CanBootstrapRelay(internal/planner/relay.go:34) — only reject when target's hash for a desired ref is non-zero and differs from the desired source hash.BuildBootstrapPlans(internal/planner/planner.go:233) — same: skip already-current refs asActionSkip, only emitActionCreatefor the rest.
Plus a couple of integration tests covering the "target has main at correct hash, source adds branches" case end-to-end, and a planner test for the relaxed CanBootstrapRelay.
This is the right shape of fix and it's the natural extension of what we did today. It's a self-contained change but a bigger one than the previous two — touches the bootstrap relay decision logic, plan building, and there are several integration tests around bootstrap eligibility that I'd want to read carefully before changing the rules.
Want me to implement Option B?
but we are still mostly guessing that this is the issue or the fix is worth is no matter what?
You're right to push back. Let me be honest about what we actually know vs. what I'm guessing.
What we actually know
- Target returns 500 with body
Internal Server Error. No further detail from the server. - Single-branch sync via bootstrap relay works (test B, one data point).
- No-op single-branch sync works (test A, irrelevant to push behavior).
- Full sync with 17837 objects in the pack fails.
- Three Cf-Ray IDs the server-side operators could look up.
What I'm guessing
- That the failure is size-related. The verbose output gives object count, not byte size. We don't know if the pack is 5 MB or 500 MB.
- That batching would fix it. If Cloudflare's failure is a deterministic backend bug unrelated to size — bad object format, an edge-case in their handler, an auth quirk — then no amount of batching helps.
- That the per-branch loop "works." I extrapolated from one branch.
- That the artifacts service has any size cap at all. Could just be a server crash that happens to correlate with this repo's content.
Cheap experiments to validate before any implementation
These three would actually tell us where the bug lives:
1. Vanilla git push --mirror to the same target. If that also 500s, it's a server bug, full stop, and Option B fixes nothing. If it succeeds, git-sync is sending something different.
2. Two branches in one git-sync invocation. Tests whether the threshold is "exactly 1 ref" or "many refs":
3. Pack byte size. Run vanilla git pack-objects locally with the same wants/haves to see how big the pack actually is. If it's <10 MB, "size limit" is implausible.
The most informative thing of all: share a Cf-Ray with whoever runs the artifacts service. They'll know in 30 seconds. We've been reverse-engineering through a 500 with no body — that's a losing game when we can just ask.
My honest recommendation
Don't implement Option B yet. The fixes already shipped (materialized walker, incremental relay for creates) are independently correct improvements. The server-side 500 is a separate problem and we haven't proven it's a git-sync problem at all. Run experiments 1 and 2, share a Cf-Ray with the server team — then decide if more code change is justified.
ok, let's commit the changes we have for now. I'll open a PR with them, and then we do another PR for the double fetch
Committed as 9c95280 on soph/fixes. Tree is clean — ready for you to push and open the PR.
When you're back for the double-fetch follow-up: the redundant call is FetchToStore at internal/syncer/syncer.go:497, which populates the in-memory store for plan building before incremental.Execute does its own FetchPack to stream to the target. For pure relay paths (all plans relay-eligible) the local store fetch is unnecessary — plan building for branch creates and FF updates only needs ref-level data and ancestry checks, not the full closure. Skipping it would halve the source-side bandwidth on relay-only syncs.
Sure:
Three fixes for syncing into targets that share reachability with the source.
- Materialized walker tolerates pruned objects. A fetch with target refs as haves prunes the closure server-side; the walker now treats missing references (top-level and transitive) as implicitly have'd by the target instead of failing with
object not found.- Incremental relay accepts branch creates. Previously fell through to materialized when target had any existing ref and source added a new branch. Also tolerates
no-thintargets — same rationale as replicate relay (the source pack is always self-contained).httpErrorsurfaces diagnostic headers (Cf-Ray,Server,X-Request-Id, …) so an opaque 500 leaves something to share with the upstream operator.
Quick clarification before I dig in: the soph/prevent-double-fetch branch is currently at main (7ac0134), while the fixes you just committed for the previous PR are on soph/fixes (9c95280).
The double-fetch optimization is most meaningful layered on top of soph/fixes — without those changes, branch creates with an existing target still fall through to materialized (which legitimately needs the full store), so skipping FetchToStore would break that path. The optimization really only pays off once relay handles the create case.
Two ways to set this up:
- Rebase
soph/prevent-double-fetchonsoph/fixes. Cleanest. The PR for this branch will sit on top of the previous PR. If you merge them in order, the dependency stays clean. - Independent of
soph/fixes. Doable, but then I need to scope the fix to only kick in for plans that don't need full closure (FF updates without store-required ancestry checks viaFetchCommitGraph), and we need a fallback for servers without v2+filter. More code, narrower benefit until both PRs merge.
Which do you want? My recommendation is (1) — rebase this branch on soph/fixes, fix is straightforward and meaningful, and the PR descriptions can note the dependency.
Committed as ee03e70 on soph/prevent-double-fetch. Branch is clean.
What landed
internal/syncer/syncer.go
- New helper
needsLocalSourceClosure(cfg, desired, targetRefs). Returns true onForce || Pruneor any divergent ref (target has it at a different hash than source). - The
FetchToStorecall inrunSyncis now gated by that helper. When false, the in-memory store stays empty —BuildPlansdoesn't touch it (no FF check needed), and incremental relay does its ownFetchPackto stream straight to target.
internal/syncer/integration_test.go
TestRun_IntegrationSkipsLocalFetchOnRelayOnlySync: target has master, source adds a new branch reachable from master. Asserts exactly 1 want across all source upload-pack POSTs. Confirmed to fail (got 2 wants) when the optimization is reverted.TestRun_IntegrationKeepsLocalFetchWhenAncestryNeeded: target seeded with master, source advances master by one commit (FF update). Asserts the relay still completes — exercises the path where the closure is needed for FF detection, so the local fetch must remain.
What this means for your full-sync scenario
For your cli.git → artifacts case, the previous PR's verbose output showed two Enumerating objects: 17837 sequences from source. With this change, only the relay's own fetch happens — one trip to GitHub per relay-only sync instead of two. Doesn't fix the artifacts-server 500 (still server-side), but it halves the source-side bandwidth and round-trip cost for any relay-only run.
Skip the upfront
FetchToStorewhen incremental relay will handle the push (no force/prune, no divergent refs). The relay does its ownFetchPackto stream the source pack to target, so the upfront fetch was redundant — every relay-only sync was paying for the source pack twice.Verified by
TestRun_IntegrationSkipsLocalFetchOnRelayOnlySync(asserts exactly 1 want across source upload-pack POSTs in a target-has-master / source-adds-release scenario) andTestRun_IntegrationKeepsLocalFetchWhenAncestryNeeded(FF updates still populate the store forReachesCommit).Depends on #N (the relay-with-existing-target fixes) — without those, branch creates fall through to materialized which legitimately needs the closure.
checked out the first branch again, can you check these linting errors:
[lint:go] internal/planner/relay.go:87:4: missing cases in switch of type planner.Action: planner.ActionDelete, planner.ActionSkip, planner.ActionBlock (exhaustive)
[lint:go] switch plan.Action {
[lint:go] ^
[lint:go] internal/planner/planner_test.go:1060:15: string fast-forward-branch-or-tag-create has 5 occurrences, make it a constant (goconst)
[lint:go] if reason != "fast-forward-branch-or-tag-create" {
[lint:go] ^
[lint:go] 2 issues:
[lint:go] * exhaustive: 1
[lint:go] * goconst: 1
Summary
relay.go— addedconst reasonIncrementalEligible = "fast-forward-branch-or-tag-create"(fixes goconst), changed the inner switch'sdefault:to explicitcase ActionDelete, ActionSkip, ActionBlock:(fixes exhaustive). The semantics are unchanged: any non-update/non-create action still falls through withincremental-branch-action-not-update-or-create.planner_test.go— five literal occurrences of the reason string replaced with the new constant. The single remaining literal is an error-message format string int.Fatalf, which goconst doesn't flag.
Ready to amend or commit on top of 9c95280, depending on whether you want this folded into the original commit or kept separate.
Test hash literal has 42 hex chars, not 40
Low Severity
The targetMain hash string contains 42 hex characters instead of the required 40 for a SHA-1 hash. plumbing.NewHash silently truncates via hex.DecodeString + copy into a [20]byte, so the extra two trailing d chars are dropped. Because every character is d, the resulting hash happens to equal the intended 40-char value, masking the typo. If this pattern were copied and modified with varying characters, the silent truncation would produce a wrong hash.
The committed string is actually 40 characters, not 42 — both locally and on origin/soph/fixes:
So the review counted wrong — likely a glance error since the visual length of 40 d's is hard to eyeball.
That said, the underlying concern about plumbing.NewHash is real: it does hex.DecodeString then copy(h[:], b), which silently truncates anything > 40 chars and silently zeros odd-length input. We're correct here, but a typo in a future copy-paste could go undetected. If you want belt-and-suspenders, a tiny test helper would catch it:
Worth doing? Up to you — for two test fixtures it might be more ceremony than the risk warrants. Reply with the call and I'll either drop the helper into planner_test.go and refactor the four hash literals, or just reply to the review noting the count was wrong.
back to soph/prevent-double-fetch:
Empty store fallback when relay rejects create plans
Medium Severity
needsLocalSourceClosure returns false when all desired refs are creates or skips, but the incremental relay can still reject all-create plans — for example, cross-kind ref mappings (branch-to-tag via Mappings) cause CanIncrementalRelay to return false ("incremental-non-tag-mapping"), and CanFullTagCreateRelay also rejects them. The materialized fallback then runs against an empty in-memory store, silently producing an insufficient pack that the target's receive-pack rejects. Before this change, FetchToStore always ran, so the fallback worked correctly.
Committed as c827f33.
Quick summary of the fix:
- Replaced the single
if needsLocalSourceClosure(...) { FetchToStore(...) }block with aclosureFetchedflag and afetchClosure()closure that fetches at most once. - Called
fetchClosure()upfront whenneedsLocalSourceClosuresays we need it (existing behavior preserved). - Also called
fetchClosure()right beforeexecuteMaterializedruns in the relay-rejected branch, so an unanticipated relay rejection (e.g.CapabilitiesKnown=false) doesn't leave materialized with an empty store.
For the test gap: I considered adding an integration test specifically for "needsLocalSourceClosure=false but CanIncrementalRelay rejects." The only realistic trigger is CapabilitiesKnown=false, which requires advertising receive-pack with a nil Capabilities field — neither receivePackThinCap nor receivePackNoThin express that, and rewriteReceivePackAdvertisement mutates an existing list rather than nilling it. Building that scaffolding seemed disproportionate to the size of the fix, especially since the fix itself is straightforward (lazy + idempotent). If you'd like, I can add a server flag and a test in a small follow-up — say the word.
Looking deeper: the CapabilitiesKnown=false branch is essentially unreachable from a real server. packp.NewAdvRefs() initializes Capabilities to a non-nil capability.List{}, and Decode doesn't replace it. So adv.Capabilities is non-nil after any successful parse, and Known: true is set whenever the advertisement parses at all.
Which means the reviewer's "the fallback runs against an empty store" concern is theoretical for the specific path they named — but the defensive lazy-fetch is still right because:
- It's forward-compatible. If
CanIncrementalRelayever grows a new rejection that isn't mirrored inneedsLocalSourceClosure, the fallback still works. - It costs zero in the happy path (the closure is fetched at most once).
That said, here's what a flag would look like if you wanted the test:
Two problems with this approach:
-
It doesn't actually produce
nilcapabilities — emptying a non-nilcapability.Liststill leaves it non-nil, soTargetFeaturesFromAdvRefswould still setKnown: true. We'd needcaps == nil, butrewriteReceivePackAdvertisement's mutate signature takes a non-nil pointer. -
The wire format always carries a capability segment in v1 (after the NUL on the first ref line, or via the synthetic
capabilities^{}ref when there are no refs). go-git's encoder writes it whether the list has entries or not. To produce a wire response that decodes withCapabilities == nil, we'd have to bypass the encoder entirely — write raw pktline bytes that omit the NUL-prefixed capability segment. That's a fair amount of test code to assert on a never-actually-triggered branch.
So the realistic answer is: don't add the test. The fix stands as a defense-in-depth change against a future hypothetical failure (someone adds a new CanIncrementalRelay rejection that needsLocalSourceClosure doesn't anticipate). If you want to surface that intent, I could either:
- Add a comment to
needsLocalSourceClosurenoting it's a best-effort approximation and the materialized fallback compensates, or - Add a small in-package unit test that constructs a
syncSessionwith a syntheticRelayTargetPolicy{CapabilitiesKnown: false}and asserts the lazy fetch fires. That bypasses the network entirely. ~40-60 lines of test boilerplate for one assertion.
Either is fine. My lean is the comment — the existing commit message already documents the intent; a one-line comment near the helper makes it discoverable from the code too.
Add a small in-package unit test that constructs a syncSession with a synthetic RelayTargetPolicy{CapabilitiesKnown: false} and asserts the lazy fetch fires. That bypasses the network entirely. ~40-60 lines of test boilerplate for one assertion.
Committed as 9e08903. The test:
- Constructs a
syncSessiondirectly vianewSession(we're in packagesyncer). - Synthesizes the otherwise-unreachable
CapabilitiesKnown=falsepolicy by overridingsess.target.policyafter construction. - Sets up source with a new commit on a new branch that target genuinely doesn't have.
- Asserts both the ref update and that the commit object actually landed on target — the latter is the regression-catching assertion (the in-memory storer happily records refs pointing at missing objects, so the ref-only check passes either way).
Confirmed it fails when the lazy-fetch is reverted (object not found for the new commit), passes with the fix.
[lint:go] internal/syncer/integration_test.go:383:2: SA4006: this value of sess is never used (staticcheck) [lint:go] sess, err := newSession(context.Background(), cfg, true) [lint:go] ^ [lint:go] 1 issues: [lint:go] * staticcheck: 1
Test still passes, lint clean. Restructured: read baselineHead first, do all source state changes (add commit, set release, reset master), then call newSession once.