Fix Silent Field Drops in Git Sync Configuration

The review agent finished (23 min ago) but its result never landed in my context, so I've asked it to restate the findings. I'll report them as soon as it replies.
No — nothing has come back yet. The agent is resuming from my request; I'll relay the findings the moment its reply arrives rather than guess at them.
All finder angles have reported and I've empirically confirmed the top findings against real git 2.53. Finalizing (the gap sweep is still running; with 26 candidates already competing for 15 slots, it can only add items below the cut).
[
{
"file": "unstable/client.go",
"line": 269,
"summary": "buildSyncConfig — the function this PR edits — silently drops req.Scope.ExcludeRefs while forwarding ExcludeRefPrefixes; buildBootstrapConfig:309 and buildFetchConfig:335 drop it too, and the stable client.go:125 does thread it.",
"failure_scenario": "Empirically confirmed: unstable.Client.buildSyncConfig with Scope{AllRefs:true, ExcludeRefs:["refs/heads/entire"], ExcludeRefPrefixes:["refs/pull/"]} returns syncer.Config{ExcludeRefs:[], ExcludeRefPrefixes:[refs/pull/]}. With Policy{Prune:true}, planner.IsRefExcluded (syncer.go:1114, planner.go:260) never matches, so the caller's reserved refs/heads/entire becomes a prune candidate and is DELETED from the target, and is overwritten from the source when present. This is exactly the silent-field-drop class the PR's new reflection guard was written to prevent; it cannot catch it because it only walks bool fields on SyncPolicy, never RefScope."
},
{
"file": "internal/syncer/empty_source.go",
"line": 147,
"summary": "The target-populated divergence check counts every advertised target ref, ignoring ExcludeRefPrefixes/ExcludeRefs and zero hashes — unlike every other consumer of s.target.refMap (syncer.go:1100/1114, planner.go:260).",
"failure_scenario": "Empirically confirmed: Config{AllowEmptySource, AllRefs, SourceAssertedEmpty, TargetAssertedEmpty, ExcludeRefPrefixes:["refs/pull/"]} with a verified-empty source and a target holding only refs/pull/1/head returns source is empty but the target still has refs (1) (errors.Is ErrSourceEmptyTargetPopulated); same for a target entry at plumbing.ZeroHash. errors.go tells callers to escalate that as real divergence, so a mirror is permanently unconvergeable over a ref the run would never touch — and refs/pull/* is the very namespace errors.go cites as the benign case. Leftover refs/gitsync/bootstrap/heads/* temp refs from an interrupted bootstrap trip it too."
},
{
"file": "internal/syncer/syncer.go",
"line": 1016,
"summary": "AllowEmptySource is entirely inert for any request using Scope.Mappings: planner.BuildDesiredRefs errors on the missing mapped source ref (planner.go:51) before the len(desiredRefs)==0 branch can reach resolveEmptyDesiredSet.",
"failure_scenario": "Empirically confirmed: Config{Mode:replicate, AllRefs:true, AllowEmptySource:true, SourceAssertedEmpty:true, TargetAssertedEmpty:true, Mappings:[{refs/heads/main->refs/heads/main}]} against a genuinely empty source returns source ref refs/heads/main not found, with errors.Is false for all four new sentinels and SourceEmpty false. The identical config without Mappings converges. A mirror caller that pins refs by mapping gets a hard, unclassifiable failure on precisely the state the feature exists to make succeed."
},
{
"file": "internal/gitproto/refs.go",
"line": 111,
"summary": "SkippedRefNames is populated at only one of four &RefService{} sites; all three v1 literals (74, 111, 123) leave it nil, and the auto->v1 fallback at :111 computes skipped at :106, warns with it at :110, then throws it away — so resolveEmptyDesiredSet's invalid-name corroboration is structurally vacuous on v1.",
"failure_scenario": "A v1 (or auto-fallback-to-v1) source advertising only names PartitionRefNames rejects, e.g. "refs/heads/bad name": refs is empty and RefService.SkippedRefNames is nil, so the guard at empty_source.go:141 — which exists precisely to catch "a repository full of refs git would reject arrives looking empty" — reads it as "nothing was dropped". It fails closed today only because the !HeadUnborn check at :136 runs first and v1 can never set HeadUnborn. Reorder those guards, relax the unborn requirement (a plausible follow-up given v1 can never converge), or add any new reader of the field, and a populated repository converges as empty. The field's godoc (refs.go:57-62) promises the data unconditionally, unlike HeadUnborn's which explicitly documents its v1 caveat. Threading it out of listSourceRefsV1 / setting it at :111, or replacing the 5-tuple with a *RefService constructor, makes it structural." omits that the sentinel needs the AllowEmptySource opt-in at all, unlike its three siblings. Same wording at internal/syncer/empty_source.go:17."
},
{
"file": "types.go",
"line": 159,
"summary": "SyncPolicy.Validate does not reject AllowEmptySource / SourceAssertedEmpty / TargetAssertedEmpty outside replicate mode, so all three are validated, threaded into syncer.Config, and then silently discarded.",
"failure_scenario": "Client.Sync (Mode unset or ModeSync) with all three flags set against an empty source hits runSync's own errors.New("no source refs matched") at syncer.go:910; Bootstrap hits the same at 1155 and Fetch at 1318. No validation error, no warning, no sentinel — the safety policy is a no-op. Validate already rejects mode-specific combinations four lines below (replicate does not support force flags), so the precedent for edge-time rejection exists. The new reflection guards pass because they assert only that the bool reached syncer.Config, never that a path reads it."
},
{
"file": "internal/syncer/empty_source.go",
"line": 136,
"summary": "Convergence is unreachable on protocol v1 and on the SSH auto->v1 fallback (HeadUnborn is only ever set on the v2 ls-refs path), yet no public doc says the policy requires v2 and Validate does not reject Protocol:ProtocolV1 + AllowEmptySource.",
"failure_scenario": "Verified against real git 2.53 over git-http-backend: two genuinely empty repos converge with SourceEmpty=true under Protocol "" and "v2", but Protocol "v1" fails with source advertised no refs but its emptiness could not be verified: source asserted empty but did not report an unborn HEAD. That message tells the operator the server is withholding refs — implying a hideRefs misconfiguration or a compromised source — when the real cause is the client's own protocol selection. The same trap fires silently for an SSH source that fails the v2 probe and falls back to v1 at refs.go:78-84."
},
{
"file": "internal/syncer/empty_source_test.go",
"line": 19,
"summary": "Every test for the feature hand-builds a &syncSession{} literal, so the wiring the whole design rests on (ls-refs unborn argument -> decodeV2LSRefs -> RefService.HeadUnborn -> Config -> converged Result) has zero coverage.",
"failure_scenario": "Mutation-verified: replacing args = append(args, \"unborn\") (internal/gitproto/refs.go:219) with a no-op leaves go test ./... entirely green, while every real empty-source run silently degrades to ErrSourceEmptyUnverified forever. Dropping HeadUnborn/SkippedRefNames from the RefService literal at refs.go:96 is equally invisible — which is how finding 4 above stays green. The in-repo fake v2 servers advertise ls-refs=unborn (integration_test.go:3849, gitproto/helper_test.go:126) but never emit an unborn line. The harness to pin it already sits in the same package: git_http_backend_test.go drives real git-http-backend over git init --bare, and a bare repo with no commits is exactly this case."
},
{
"file": "internal/syncer/syncer.go",
"line": 192,syncer.go:185-189, results.go:131-137 and empty_source_test.go:55 all say the field exists to make is dropped on that path."
},
{
"file": "internal/syncer/empty_source.go",
"line": 159,
"summary": "The converged Result omits Relay / RelayMode / RelayReason (and SourceHEAD), which every other successful replicate return path sets (syncer.go:1059, 1253).",
"failure_scenario": "fromSyncResult yields ExecutionSummary{OperationMode:"replicate", Relay:false, TransferMode:"", Reason:"", SourceEmpty:true}. A consumer relying on the invariant "a successful replicate always reports Relay=true with a non-empty TransferMode" — true at both other return sites, and the reason replicate refuses non-relay targets at syncer.go:1024 — classifies the converged run as a materialized/fallback replicate or as a malformed result."
},
{
"file": "internal/syncer/empty_source.go",
"line": 147,
"summary": "Nil-guard asymmetry: s.sourceService is nil-checked at line 136 on a path where it can never be nil (newSession already dereferences it at syncer.go:804), while s.target is dereferenced unguarded at 147 and 156 on a session type that legitimately has target == nil.",
"failure_scenario": "newSession only builds s.target when needTarget is true (syncer.go:815). Safe today only by accident of call graph — runReplicate is the sole caller and Run always passes true — but Fetch (syncer.go:1189) and target-less Probe build needTarget=false sessions and hit the sibling no source refs matched errors this function is billed as superseding ("the only place that may conclude the source is empty"). Any such reuse panics with a nil-pointer deref at len(s.target.refMap) instead of returning an error, and only on the empty-desired-set branch, so no test that syncs a non-empty source catches it. The dead guard plus the missing one reads as an oversight rather than an invariant."
},
{
"file": "client_test.go",
"line": 299,
"summary": "The new reflection guard has no field.IsExported() filter and no kind check on the Config side, so it panics with an opaque reflect error instead of reporting the field; it is also duplicated near-verbatim in unstable/client_test.go:170 and both open with an always-empty skip map whose lookup branch is dead.",
"failure_scenario": "Verified empirically: policyType.Field(i) enumerates unexported fields, so adding any unexported bool to SyncPolicy makes both copies panic reflect: reflect.Value.SetBool using value obtained using unexported field rather than naming the field. Symmetrically, a future SyncPolicy bool with a same-named non-bool counterpart in syncer.Config panics at got.Bool() instead of reaching the intended t.Fatalf that tells the author to thread it or add a skip reason. ~30 of the ~36 lines are byte-identical between the two files and must be edited in lockstep; they have already drifted (the unstable copy carries a "One field at a time" comment the root copy lacks). internal/syncertest exists and is already imported by client_test.go."
},
{
"file": "internal/gitproto/refs.go",
"line": 62,
"summary": "SkippedRefNames and its twin targetSession.skippedRefNames (syncer.go:722) retain the full []string for the whole run when every non-test read is len(...) > 0 (empty_source.go:141, 156); before this diff the slice was consumed by WarnSkippedRefNames and dropped.",
"failure_scenario": "The slices are pinned on RefService and targetSession, which live on syncSession across the pack fetch and push — the minutes-long part of a run. A source or target advertising N invalid ref names now holds roughly N x (16 + len(name)) bytes for that whole window purely to answer a boolean: at 100k dropped names of ~40 bytes, ~5-6 MB retained per side per concurrent sync. An int count (or a bool) gives both guards everything they use and lets the names go right after the warning."
},
{
"file": "docs/protocol.md",
"line": 147,
"summary": "The verbatim ls-refs request block and the paragraph enumerating its arguments were not updated for the conditionally appended unborn argument or for RefService.HeadUnborn — and the argument goes on the wire for every v2 source listing, not just AllowEmptySource callers.",
"failure_scenario": "The doc sthen describes decodeV2LSRefs as extracting symref-target with no mention of the unborn line. This is the section that exists to record wire behavior, so a maintainer diffing it against listSourceRefsV2 concludes the request is unchanged. It also masks that the change is not as inert as the CHANGELOG's "nothing changes for existing callers" claims: every v2 server advertising ls-refs=unborn now receives an extra argument on every sync, probe, fetch and bootstrap, whether or not the caller opted in."
}
]
Scope note: local main was stale, so the review base is origin/main (2a61c8c7) — the four empty-source commits, not the 12 commits main...HEAD would have shown.
Findings 1, 2, 3, 8, 9, and 13 were confirmed empirically (unit probes, a real git-http-backend end-to-end run against git 2.53, a mutation test, and a standalone reflect repro); the working tree is clean and go test ./..., go vet, and golangci-lint all pass on the branch as committed. Findings 1–3 are the ones I would block on: each is a live wrong answer, and 2 and 3 defeat the feature on its own headline use cases (an excluded-namespace mirror, and a mapping-pinned mirror). No CLAUDE.md exists at user, repo, or directory level, so the conventions angle returned nothing.
⏺ Background command "Wait for sweep agent" completed (exit code 0)
That notification was one of my own wait timers finishing, not new review data. The review is complete and the findings above stand — no changes.
⏺ Background command "Continue waiting for sweep" completed (exit code 0)
Those two notifications were my own wait timers, not the sweep agent — the Phase 3 gap sweep is still running. The review output I delivered in my previous message stands as final: 15 findings, ranked, with findings 1, 2, 3, 8, 9, and 13 confirmed empirically.
If the sweep returns anything, it can only compete for slots already oversubscribed (26 surviving candidates for 15 slots), and I'll only revise the report if it surfaces something more severe than the current #15. Nothing to add otherwise.
⏺ Background command "Continue waiting for sweep" completed (exit code 0)
Another wait timer, not the sweep. Let me cancel the remaining timers so they stop generating noise.
⏺ Stop Task sleep 300; echo waited · stopped
⏺ Stop Task sleep 300; echo waited · stopped
⏺ Stop Task sleep 300; echo waited · stopped
⏺ Stop Task sleep 300; echo waited · stopped
⏺ Stop Task sleep 300; echo waited · stopped
Ran 1 shell command
Wait timers cancelled; working tree confirmed clean (no leftover probe files from any agent). The review is complete and the findings I delivered earlier are final.
Review summary
Scope correction worth flagging: l
The three I would block on:
- unstable/client.go:269 — buildSyncConfig, the function this PR edits, drops Scope.ExcludeRefs while forwarding ExcludeRefPrefixes; the stable client.go:125 threads it. Under Prune the caller's excluded refs become prune candidates and get deleted. This is precisely the silent-field-drop class the PR's new reflection guard was written to prevent — it can't catch it because it only walks bool fields on SyncPolicy.
- internal/syncer/empty_source.go:147 — the divergence check counts every advertised target ref, ignoring ExcludeRefPrefixes/ExcludeRefs and zero hashes, unlike every other consumer of s.target.refMap. A mirror whose target holds only excluded refs is permanently unconvergeable, and refs/pull/* is the namespace errors.go itself cites as benign.
- internal/syncer/syncer.go:1016 — AllowEmptySource is inert for any request using Scope.Mappings: BuildDesiredRefs errors with source ref X not found before the empty-desired-set branch is reachable, matching no sentinel.
Findings 2 and 3 defeat the feature on its own headline use cases, which is what makes them worse than their line counts suggest. Behind them: the undocumented AllRefs requirement, two unreachable documented causes for ErrNoRefsSelected, v1/SSH-fallback convergence being impossible with a misleading error, and a coverage gap decisive enough that deleting the unborn ls-refs argument leaves the entire suite green.
go test ./..., go vet, and golangci-lint all pass on the branch as committed — none of these surface through the existing gates.
❯ Please restate your final review result for me (the main session did not receive it): the list of verified findings with file:line, severity ordering, and a one-line statement of each defect plus its failure scenario. If nothing survived verification, say so explicitly.
Restating the final review result. The output contract is a JSON array of at most 15 findings, ranked most-severe first. Nothing was empty — 15 findings survived verification.
Scope: local main was stale (4e816de7), so the review base is origin/main (2a61c8c7) — the four empty-source commits, 858 insertions across 15 files. Not the 12 commits main...HEAD would have shown.
[
{
"file": "unstable/client.go",
"line": 269,
"summary": "buildSyncConfig — the function this PR edits — silently drops req.Scope.ExcludeRefs while forwarding ExcludeRefPrefixes; buildBootstrapConfig:309 and buildFetchConfig:335 drop it too, and the stable client.go:125 does thread it.",
"failure_scenario": "Empirically confirmed: unstable.Client.buildSyncConfig with Scope{AllRefs:true, ExcludeRefs:["refs/heads/entire"], ExcludeRefPrefixes:["refs/pull/"]} returns syncer.Config{ExcludeRefs:[], ExcludeRefPrefixes:[refs/pull/]}. With Policy{Prune:true}, planner.IsRefExcluded (syncer.go:1114, planner.go:260) never matches, so the caller's reserved refs/heads/entire becomes a prune candidate and is DELETED from the target, and is overwritten from the source when present. This is exactly the silent-field-drop class the PR's new reflection guard was written to prevent; it cannot catch it because it only walks bool fields on SyncPolicy, never RefScope."
},
{
"file": "internal/syncer/empty_source.go",
"line": 147,
"summary": "The target-populated divergence check counts every advertised target ref, ignoring ExcludeRefPrefixes/ExcludeRefs and zero hashes — unlike every other consumer of s.target.refMap (syncer.go:1100/1114, planner.go:260).",
"failure_scenario": "Empirically confirmed: Config{AllowEmptySource, AllRefs, SourceAssertedEmpty, TargetAssertedEmpty, ExcludeRefPrefixes:["refs/pull/"]} with a verified-empty source and a target holding only refs/pull/1/head returns source is empty but the target still has refs (1) (errors.Is ErrSourceEmptyTargetPopulated); same for a target entry at plumbing.ZeroHash. errors.go tells callers to escalate that as real divergence, so a mirror is permanently unconvergeable over a ref the run would never touch — and refs/pull/* is the very namespace errors.go cites as the benign case. Leftover refs/gitsync/bootstrap/heads/* temp refs from an interrupted bootstrap trip it too."
},
{
{
"file": "types.go",
"line": 155,
"summary": "AllowEmptySource silently requires Scope.AllRefs (empty_source.go:127), but the public godoc never says so and neither SyncPolicy.Validate (types.go:159) nor client.go's validateSyncFields — which sees both scope and policy — rejects the combination.",
"failure_scenario": "Replicate with the default Scope{} (AllRefs false) plus Policy{AllowEmptySource:true, SourceAssertedEmpty:true, TargetAssertedEmpty:true} against two genuinely empty repos returns the bare historical sync: no source refs matched, matching none of the four sentinels, with ExecutionSummary.SourceEmpty false — while types.go:144-155 documents that it "succeeds with zero plans and ExecutionSummary.SourceEmpty set". A caller that opts in, follows the doc, and switches to errors.Is sees an unclassifiable hard failure and never learns the policy did nothing."
},
{
"file": "errors.go",
"line": 32,
"summary": "ErrNoRefsSelected's public godoc names three scoping mechanisms — "branch selection, ref mappings, exclude prefixes" — of which two are unreachable by construction; only exclude prefixes/refs can ever produce it.",
"failure_scenario": "empty_source.go:127 returns the plain historical error unless AllRefs is set, and planner.normalizeAllRefs (planner.go:125-129) nils cfg.Branches whenever AllRefs is set, so branch selection and the gate are mutually exclusive; a mapping whose source ref is absent errors earlier in addManaged (planner.go:51). A caller who follows the doc and writes errors.Is(err, ErrNoRefsSelected) for Scope{Branches:["release"]} against a source holding only refs/heads/main never matches — they get no source refs matched and must keep substring-matching the historical text forever, which is the exact fragility the sentinels were introduced to retire. The doc also omits that the sentinel needs the AllowEmptySource opt-in at all, unlike its three siblings. Same wording at internal/syncer/empty_source.go:17."
},
{
"file": "types.go",
"line": 159,
"summary": "SyncPolicy.Validate does not reject AllowEmptySource / SourceAssertedEmpty / TargetAssertedEmpty outside replicate mode, so all three are validated, threaded into syncer.Config, and then silently discarded.",
"failure_scenario": "Client.Sync (Mode unset or ModeSync) with all three flags set against an empty source hits runSync's own errors.New("no source refs matched") at syncer.go:910; Bootstrap hits the same at 1155 and Fetch at 1318. No validation error, no warning, no sentinel — the safety policy is a no-op. Validate already rejects mode-specific combinations four lines below (replicate does not support force flags), so the precedent for edge-time rejection exists. The new reflection guards pass because they assert only that the bool reached syncer.Config, never that a path reads it."
},
{
"file": "internal/syncer/empty_source.go",
"line": 136,
"summary": "Convergence is unrthe real cause is the client's own protocol selection. The same trap fires silently for an SSH source that fails the v2 probe and falls back to v1 at refs.go:78-84."
},
{
"file": "internal/syncer/empty_source_test.go",
"line": 19,
"summary": "Every test for the feature hand-builds a &syncSession{} literal, so the wiring the whole design rests on (ls-refs unborn argument -> decodeV2LSRefs -> RefService.HeadUnborn -> Config -> converged Result) has zero coverage.",
"failure_scenario": "Mutation-verified: replacing args = append(args, \"unborn\") (internal/gitproto/refs.go:219) with a no-op leaves go test ./... entirely green, while every real empty-source run silently degrades to ErrSourceEmptyUnverified forever. Dropping HeadUnborn/SkippedRefNames from the RefService literal at refs.go:96 is equally invisible — which is how finding 4 above stays green. The in-repo fake v2 servers advertise ls-refs=unborn (integration_test.go:3849, gitproto/helper_test.go:126) but never emit an unborn line. The harness to pin it already sits in the same package: git_http_backend_test.go drives real git-http-backend over git init --bare, and a bare repo with no commits is exactly this case."
},
{
"file": "internal/syncer/syncer.go",
"line": 192,
"summary": "Result.Lines() renders every other discriminating field (DryRun:201, BootstrapSuggested:207, Batching:210, SourceHEAD:213) but was not updated for SourceEmpty, so the text output of a converged empty replicate is byte-identical to an ordinary zero-work sync.",
"failure_scenario": "A converged run prints only summary: pushed=0 deleted=0 skipped=0 blocked=0 warned=0 mode=replicate protocol=v2 relay=false relay-mode= relay-reason= batching=false batch-count=0 planned-batches=0 — the same line a no-op sync prints. Only --json carries sourceEmpty. cmd/git-sync/root.go:43 printOutput and every unstable consumer (unstable.Result aliases syncer.Result) use Lines() for non-JSON output, so the exact distinction syncer.go:185-189, results.go:131-137 and empty_source_test.go:55 all say the field exists to make is dropped on that path."
},
{
"file": "internal/syncer/empty_source.go",
"line": 159,
"summary": "The converged Result omits Relay / RelayMode / RelayReason (and SourceHEAD), which every other successful replicate return path sets (syncer.go:1059, 1253).",
"failure_scenario": "fromSyncResult yields ExecutionSummary{OperationMode:"replicate", Relay:false, TransferMode:"", Reason:"", SourceEmpty:true}. A consumer relying on the invariant "a successful replicate always reports Relay=true with a non-empty TransferMode" — true at both other return sites, and the reason replicate refuses non-relay targets at syncer.go:1024 — classifies the converged run as a materialized/fallback replicate or as a malformed result."
},
{
"file": "internal/syncer/empty_source.go",
"line": 147,
"summary": "Nil-guard asymmetry: s.sourceService is nil-checked at line 136 on a path where it can never be nil (newSession already dereferences it at syncer.go:804), while s.target is dereferenced unguarded at 147 and 156 on malformed result."
},
{
"file": "internal/syncer/empty_source.go",
"line": 147,
"summary": "Nil-guard asymmetry: s.sourceService is nil-checked at line 136 on a path where it can never be nil (newSession already dereferences it at syncer.go:804), while s.target is dereferenced unguarded at 147 and 156 on a session type that legitimately has target == nil.",
"failure_scenario": "newSession only builds s.target when needTarget is true (syncer.go:815). Safe today only by accident of call graph — runReplicate is the sole caller and Run always passes true — but Fetch (syncer.go:1189) and target-less Probe build needTarget=false sessions and hit the sibling no source refs matched errors this function is billed as superseding ("the only place that may conclude the source is empty"). Any such reuse panics with a nil-pointer deref at len(s.target.refMap) instead of returning an error, and only on the empty-desired-set branch, so no test that syncs a non-empty source catches it. The dead guard plus the missing one reads as an oversight rather than an invariant."
},
{
"file": "client_test.go",
"line": 299,
"summary": "The new reflection guard has no field.IsExported() filter and no kind check on the Config side, so it panics with an opaque reflect error instead of reporting the field; it is also duplicated near-verbatim in unstable/client_test.go:170 and both open with an always-empty skip map whose lookup branch is dead.",
"failure_scenario": "Verified empirically: policyType.Field(i) enumerates unexported fields, so adding any unexported bool to SyncPolicy makes both copies panic reflect: reflect.Value.SetBool using value obtained using unexported field rather than naming the field. Symmetrically, a future SyncPolicy bool with a same-named non-bool counterpart in syncer.Config panics at got.Bool() instead of reaching the intended t.Fatalf that tells the author to thread it or add a skip reason. ~30 of the ~36 lines are byte-identical between the two files and must be edited in lockstep; they have already drifted (the unstable copy carries a "One field at a time" comment the root copy lacks). internal/syncertest exists and is already imported by client_test.go."
},
{
"file": "internal/gitproto/refs.go",
"line": 62,
"summary": "SkippedRefNames and its twin targetSession.skippedRefNames (syncer.go:722) retain the full []string for the whole run when every non-test read is len(...) > 0 (empty_source.go:141, 156); before this diff the slice was consumed by WarnSkippedRefNames and dropped.",
"failure_scenario": "The slices are pinned on RefService and targetSession, which live on syncSession across the pack fetch and push — the minutes-long part of a run. A source or target advertising N invalid ref names now holds roughly N x (16 + len(name)) bytes for that whole window purely to answer a boolean: at 100k dropped names of ~40 bytes, ~5-6 MB retained per side per concurrent sync. An int count (or a bool) gives both guards everything they use and lets the names go right after the warning."
},
{
"file": "docs/protocol.md",
"line": 147,
"summary": "The verbatim ls-refs request block and the paragraph enumerating its arguments were not updated for the conditionally appended unborn argument or for RefService.HeadUnborn — and the argument goes on the wire for every v2 source listing, not just AllowEmptySource callers.",
"failure_scenario": "The doc shows the request as peel / symrefs / ref-prefix HEAD / ref-prefix refs/heads/ / ref-prefix refs/tags/ and explains only that "The peel and symrefs arguments are ls-refs request features...", then describes decodeV2LSRefs as extracting symref-target with no mention of the unborn line. This is the section that exists to record wire behavior, so a maintainer diffing it against listSourceRefsV2 concludes the request is unchanged. It also masks that the change is not as inert as the CHANGELOG's "nothing changes for existing callers" claims: every v2 server advertising ls-refs=unborn now receives an extra argument on every sync, probe, fetch and bootstrap, whether or not the caller opted in."
}
]
Verification status: findings 1, 2, 3, 8, 9, and 13 were confirmed empirically — unit probes against resolveEmptyDesiredSet, an end-to-end run through the repo's own git-http-backend harness against real git 2.53, a mutation test on the unborn argument, and a standalone reflect repro. The rest are confirmed by direct code reading with the quoted lines. Working tree is clean; go test ./..., go vet, and golangci-lint all pass on the branch as committed, so none of these surface through existing gates.
Blockers: 1, 2, and 3. Each is a live wrong answer, and 2 and 3 defeat the feature on its own headline use cases (an excluded-namespace mirror, and a mapping-pinned mirror).
Two process notes: the Phase 3 gap sweep never returned — it was still running when I finalized, and since 26 candidates were already competing for 15 slots it could only have added items below the cut. And the conventions angle returned nothing because no CLAUDE.md exists at user, repo, or directory level.
❯ can you list the findings in order of priority?
The JSON I returned was already ranked most-severe first, but here it is as a readable priority list.
Block on these — live wrong answers
-
unstable/client.go:269 — Scope.ExcludeRefs silently dropped The function this PR edits forwards ExcludeRefPrefixes but not ExcludeRefs; the stable client.go:125 threads both. Under Prune, the caller's excluded refs become prune candidates and get deleted. Confirmed empirically. This is the exact silent-field-drop class the PR's new reflection guard was added to prevent — it can't see it, because it only walks bool fields on SyncPolicy, never RefScope.
-
internal/syncer/empty_source.go:147 — divergence check ignores exclusions Counts every advertised target ref, ignoring ExcludeRefPrefixes/ExcludeRefs and zero hashes, unlike every other consumer of s.target.refMap. A target holding only excluded refs is reported as permanent divergence and can never converge — and refs/pull/* is the namespace errors.go itself cites as the benign case. Confirmed empirically.
-
internal/syncer/syncer.go:1016 — feature inert with Scope.Mappings BuildDesiredRefs errors with source ref X not found before the empty-desired-set branch is reachable, so a mapping-pinned mirror gets a hard failure matching no sentinel on exactly the state the feature makes succeed. Confirmed empirically.
Should fix before merge — correctness and contract gaps
-
internal/gitproto/refs.go:111 — SkippedRefNames populated at 1 of 4 sites All three v1 &RefService{} literals leave it nil; the :111 site computes skipped, warns with it, then discards it. The invalid-name corroboration is structurally vacuous on v1, fails closed today only because the !HeadUnborn check happens to run first.
-
types.go:155 — AllowEmptySource silently requires Scope.AllRefs Undocumented in the godoc and unvalidated. Opting in with the default scope returns the bare historical error with SourceEmpty false, directly contradicting the documented behavior.
-
errors.go:32 — two of three documented causes for ErrNoRefsSelected are unreachable "Branch selection" is mutually exclusive with the AllRefs gate (normalizeAllRefs nils Branches); a missing mapping errors earlier. Only exclude prefixes can produce it, so callers who follow the doc must keep substring-matching the old text — the fragility the sentinels were meant to retire.
-
types.go:159 — Validate doesn't gate the new flags to replicate mode All three are accepted, threaded, then discarded by runSync, Bootstrap, and Fetch. Validate already rejects mode-specific combinations four lines below.
-
internal/syncer/empty_source.go:136 — convergence impossible on v1 / SSH fallback HeadUnborn is only set on the v2 path. The error blames the server for withholding refs when the real cause is the client's own protocol choice. Verified against git 2.53.
-
internal/syncer/empty_source_test.go:19 — zero end-to-end coverage All tests hand-build &syncSession{}. Mutation-verified: deleting the unborn ls-refs argument leaves the whole suite green. This is how finding 4 stays green. The harness to pin it is already in the same package.
-
internal/syncer/syncer.go:192 — Lines() never renders SourceEmpty Converged text output is byte-identical to a no-op sync, dropping the exact distinction three separate doc comments say the field exists to make. Only --json carries it.
-
internal/syncer/empty_source.go:159 — converged Result omits Relay/RelayMode/RelayReason Every other successful replicate return sets them, and replicate refuses non-relay targets — so a consumer relying on that invariant misclassifies the run.
-
internal/syncer/empty_source.go:147 — nil-guard asymmetry s.sourceService is guarded where it can't be nil; s.target is dereferenced unguarded on a session type that legitimately has target == nil. Safe only by accident of call graph.
-
client_test.go:299 — reflection guard panics instead of reporting No IsExported() filter, so any unexported bool on SyncPolicy makes both copies panic with an opaque reflect error; got.Bool() panics on a non-bool counterpart. Duplicated near-verbatim in unstable/client_test.go:170, already drifting, with a dead always-empty skip map.
Lower priority — cleanup and docs
-
internal/gitproto/refs.go:62 — full []string retained for a len() read Both slices are pinned on long-lived objects across the pack transfer purely to answer a boolean; before this diff they were consumed and dropped. An int gives both guards everything they use.
-
docs/protocol.md:147 — wire-behavior doc not updated for unborn The verbatim ls-refs request block and its argument prose still omit the new argument, which goes on the wire for every v2 listing regardless of opt-in — so it also understates the CHANGELOG's "nothing changes for existing callers" claim.
One caveat on the ordering: 1 through 3 are ranked by whether they produce a wrong answer today, and 2 and 3 sit that high because they defeat the feature on its own headline use cases, not because of their line counts. Findings 1, 2, 3, 8, 9, and 13 were confirmed empirically; the rest by direct code reading. Everything on this list passes go test ./..., go vet, and golangci-lint as committed.
Yes — 15 findings, ranked. Review base was origin/main (2a61c8c7), i.e. the four empty-source commits only, since local main was stale. Findings 1, 2, 3, 8, 9, 13 were confirmed empirically (unit probes, a real git-http-backend run against git 2.53, a mutation test, a reflect repro); the rest by code reading. go test ./..., go vet, golangci-lint all pass as committed — none of these surface through existing gates.
Blockers — live wrong answers
unstable/client.go:269—buildSyncConfig(the function this PR edits) forwardsExcludeRefPrefixesbut silently dropsScope.ExcludeRefs; stableclient.go:125threads both. UnderPrune, the caller's excluded refs become prune candidates and get deleted. Exactly the silent-field-drop class the new reflection guard was written to catch — it can't, because it only walks bool fields onSyncPolicy, neverRefScope. (buildBootstrapConfig:309andbuildFetchConfig:335drop it too.)internal/syncer/empty_source.go:147— the target-populated divergence check counts every advertised target ref, ignoring exclusions and zero hashes, unlike every others.target.refMapconsumer. A target holding onlyrefs/pull/*is reported as permanent divergence and can never converge — and that's the namespaceerrors.goitself cites as benign. Leftoverrefs/gitsync/bootstrap/heads/*temp refs trip it too.internal/syncer/syncer.go:1016—AllowEmptySourceis inert for any request usingScope.Mappings:BuildDesiredRefserrorssource ref X not foundbefore the empty-desired-set branch is reachable, matching no sentinel. The same config without mappings converges.
2 and 3 defeat the feature on its own headline use cases (excluded-namespace mirror, mapping-pinned mirror).
Should fix before merge
internal/gitproto/refs.go:111—SkippedRefNamesset at 1 of 4&RefService{}sites; the v1 fallback computesskipped, warns, then discards it. The invalid-name corroboration is vacuous on v1, failing closed only because the!HeadUnborncheck happens to run first.types.go:155—AllowEmptySourcesilently requiresScope.AllRefs; undocumented and unvalidated. Opting in with the default scope returns the bare historical error withSourceEmptyfalse, contradicting the godoc.errors.go:32— two of the three documented causes forErrNoRefsSelectedare unreachable (branch selection is mutually exclusive with theAllRefsgate; a missing mapping errors earlier). Callers who follow the doc must keep substring-matching the old text.types.go:159—Validatedoesn't gate the three new flags to replicate mode; they're threaded then discarded by sync/bootstrap/fetch. Precedent for edge-time rejection is four lines below.internal/syncer/empty_source.go:136— convergence is impossible on v1 and on the SSH auto→v1 fallback (HeadUnbornis v2-only), and the error blames the server for withholding refs when the cause is the client's own protocol choice.internal/syncer/empty_source_test.go:19— all tests hand-build&syncSession{}; deleting theunbornls-refs argument leaves the whole suite green. That's why #4 stays green. Thegit-http-backendharness is already in the same package.internal/syncer/syncer.go:192—Lines()never rendersSourceEmpty, so converged text output is byte-identical to a no-op sync; only--jsoncarries it.internal/syncer/empty_source.go:159— convergedResultomitsRelay/RelayMode/RelayReason/SourceHEAD, which both other successful replicate returns set.internal/syncer/empty_source.go:147— nil-guard asymmetry:sourceServiceguarded where it can't be nil,s.targetdereferenced unguarded on a session type that legitimately hastarget == nil.client_test.go:299— the reflection guard has noIsExported()filter, so any unexported bool onSyncPolicypanics with an opaque reflect error instead of naming the field; near-verbatim duplicate atunstable/client_test.go:170, already drifted, plus a dead always-emptyskipmap.
Lower priority
internal/gitproto/refs.go:62— full[]stringretained across the pack transfer on both sides purely for alen() > 0read (~5-6 MB per side at 100k dropped names); an int suffices.docs/protocol.md:147— the verbatim ls-refs request block and its argument prose omitunborn, which now goes on the wire for every v2 listing regardless of opt-in.
One gap: the Phase 3 sweep hadn't returned when the agent finalized, though with 26 candidates already competing for 15 slots it could only add items below the cut. Want me to fix the three blockers?
Fix it — the approach is sound. I read empty_source.go and the call site to check whether the findings cluster into a design problem, and they don't.
The core design holds up: caller-supplied assertion (git can't prove emptiness), git-side observations that can only ever refuse, fail-closed defaults, separate sentinels for converged / unknown / diverged. The doc comment's reasoning about hideRefs and unborn HEAD is correct, and the target half being asserted separately is right for the reason it states.
What the findings actually are:
One misplaced check. runReplicate hooks emptiness at len(desiredRefs) == 0 after planner.BuildDesiredRefs (syncer.go:1014-1018). With Mappings, addManaged errors on the absent source ref first — that's finding 3, and it's a reorder, not a redesign: with AllRefs the advertisement is already refs/ (planner/types.go:186), so len(s.sourceRefMap) == 0 is a complete observation and can be resolved before planning runs.
One wrong predicate. len(s.target.refMap) > 0 at line 147 needs the same exclusion filter every other refMap consumer uses. Small.
One genuine decision for you. Finding 8: HeadUnborn only exists on the v2 path, so convergence is unreachable on v1 and on the SSH v2→v1 fallback, with an error that blames the server. Either reject ProtocolV1 + AllowEmptySource at validation, or accept the assertion without unborn corroboration on v1 — the second weakens the guarantee on exactly the protocol where you can't check it. I'd reject at validation and document it.
Everything else is docs, validation, and tests. Notably the AllRefs requirement (finding 5) is legitimate — without it RefPrefixes narrows the listing to refs/heads/, so "nothing advertised" really is a partial view. It just needs saying in the godoc and rejecting in Validate, not changing. Same for gating the flags to replicate mode (7) and correcting ErrNoRefsSelected's unreachable causes (6).
Two notes on sequencing: finding 1 (ExcludeRefs dropped in unstable/client.go) is a pre-existing bug that deletes refs under Prune today, independent of this feature — worth its own commit, arguably its own PR. And finding 9 (deleting the unborn argument leaves the suite green) is what lets 4 and several others hide; the git-http-backend harness is already in that package, so it's the highest-leverage single item after the blockers.
Want me to start with the three blockers, or the whole list?
Block on these — live wrong answers
unstable/client.go:269—Scope.ExcludeRefssilently dropped; underPrunethe caller's excluded refs get deleted. Confirmed empirically.internal/syncer/empty_source.go:147— divergence check ignores exclusions and zero hashes, so a target holding only excluded refs can never converge. Confirmed empirically.internal/syncer/syncer.go:1016— feature entirely inert withScope.Mappings; hard failure matching no sentinel. Confirmed empirically.
Should fix before merge
internal/gitproto/refs.go:111—SkippedRefNamespopulated at 1 of 4 sites; corroboration vacuous on v1.types.go:155—AllowEmptySourcesilently requiresScope.AllRefs; undocumented, unvalidated.errors.go:32— two of three documented causes forErrNoRefsSelectedare unreachable by construction.types.go:159—Validatedoesn't gate the new flags to replicate mode.internal/syncer/empty_source.go:136— convergence impossible on v1/SSH fallback; error blames the server. Verified against git 2.53.internal/syncer/empty_source_test.go:19— zero end-to-end coverage. Mutation-verified.internal/syncer/syncer.go:192—Lines()never rendersSourceEmpty.internal/syncer/empty_source.go:159— convergedResultomitsRelay/RelayMode/RelayReason.internal/syncer/empty_source.go:147— nil-guard asymmetry;s.targetdereferenced unguarded.client_test.go:299— reflection guard panics instead of reporting; duplicated verbatim inunstable/client_test.go:170. Panic verified.
Changed by the sweep
14. internal/gitproto/refs.go:219 — the unborn argument is ungated (NEW, replaces the docs item)
listSourceRefsV2 takes no Config, so unborn goes on the wire for probe, plan, sync, replicate, bootstrap, fetch and convert-sha256 alike — while HeadUnborn is read only by resolveEmptyDesiredSet under AllowEmptySource. Confirmed by inspection: there is no parameter through which the opt-in could reach that line. This falsifies the CHANGELOG's and types.go's central claim that with the opt-in unset "nothing changes for existing callers" — the wire request changes for every v2 caller, with no way to suppress it. My earlier docs/protocol.md:147 finding is the documentation half of this same change and folds in here.
15. internal/syncer/syncer.go:189 + results.go:137 — the new public field's contract (NEW, merged from two sweep items)
Two permanent-once-released problems on one field. The name says "source" but the flag is set only when source and target are both verified empty, so false does not mean "the source has refs" — it is false in all three of the other empty-desired-set outcomes, including ErrSourceEmptyTargetPopulated where the source was verified empty. Converged would carry the actual meaning. Separately it is the only bool in ExecutionSummary (and in syncer.Result) tagged omitempty — DryRun, Relay, BootstrapSuggested, Batching are all emitted unconditionally — so for the field whose whole purpose is to discriminate converged from no-op, "false" and "this binary has no such field" are the same JSON.
Displaced below the cut: the SkippedRefNames retention issue (refs.go:62, full []string held for a len() read) and the standalone docs/protocol.md gap, plus three minor sweep items — a nil err.Error() deref in empty_source_test.go:203 that panics in exactly the case the assertion exists to catch, fetch_test.go:308 being a strict superset of the pre-existing TestDecodeV2LSRefsEmpty, and the unlabelled (%d) count in the divergence error at empty_source.go:148 while both sibling messages label theirs.
Blockers remain 1, 2, 3. The sweep confirmed the top of the list independently and reported go vet, go test ./... and golangci-lint clean with a clean working tree, which matches what I saw — none of these 15 surface through existing gates.</result>
<usage><subagent_tokens>209034</subagent_tokens><tool_uses>94</tool_uses><duration_ms>1849331</duration_ms></usage>
</task-notification>
Sweep landed — top 13 unchanged, tail changed, and one new item I'd promote above the rest of the "before merge" tier because it freezes on release.
15 → SourceEmpty's public contract (internal/syncer/syncer.go:189, results.go:137). I verified both halves:
- The name is wrong, and its own godoc says so: "the source was verified to have no refs and the target had none either."
falsedoesn't mean "the source has refs" — it's false forErrSourceEmptyTargetPopulatedtoo, where the source was verified empty.Convergedcarries the actual meaning. - Both declarations are tagged
omitempty, whileRelay,BootstrapSuggested,DryRunandBatchingare all unconditional. So for the one field whose stated purpose is "what distinguishes that converged state from an ordinary sync that happened to have no work to do,"falseand "this binary has no such field" are the same JSON.
Combined with finding 10 (Lines() never renders it), the field currently fails its documented purpose on both output paths — and the rename stops being available the moment this ships.
14 → the unborn argument is ungated (internal/gitproto/refs.go:219). listSourceRefsV2 takes no Config, so it goes on the wire for probe, plan, sync, bootstrap, fetch and convert-sha256, while HeadUnborn is read only under AllowEmptySource. That falsifies the CHANGELOG's "nothing changes for existing callers" — worth correcting the release note text either way, whether or not you gate the argument. The old docs/protocol.md item folds in here.
Displaced below the cut: the SkippedRefNames retention issue, plus three minor items (a nil err.Error() deref at empty_source_test.go:203 that panics in exactly the case its assertion exists to catch, a redundant fetch_test.go:308, and an unlabelled (%d) in the divergence error).
Nothing here changes the verdict — still a reorder, a predicate, a decision, and a docs/validation/test pass, not a redesign. My question from before still stands: three blockers first, or the whole list? I'd fold #15's rename in with the blockers regardless, since it's the only one with a deadline.
Done — 13 of 15 fixed, plus the four below-the-cut items. All in the working tree, uncommitted.
Fixed
| # | Change |
|---|---|
| 1 | ExcludeRefs threaded through all three unstable builders |
| 2 | Divergence check now filters via planner.IsRefExcluded + zero hashes (targetRefsInScope) |
| 3 | Empty advertisement resolved before BuildDesiredRefs, gated on the opt-in so un-opted-in callers keep the planner's error verbatim |
| 4 | SkippedRefNames []string → SkippedRefCount int, set at all four sites via newV1RefService |
| 5 | AllRefs requirement documented and rejected at both edges (validateSyncFields, validateEmptySourcePolicy) |
| 6 | ErrNoRefsSelected doc corrected in both copies — exclusions are the only reachable cause |
| 7 | Mode gating in SyncPolicy.Validate + newSession |
| 9 | End-to-end coverage; the fake v2 server now emits an unborn line like real git |
| 10 | Lines() renders a converged: line |
| 11 | Relay/RelayMode/RelayReason set on the converged result |
| 12 | Real nil guard on s.target |
| 13 | One generic guard in internal/syncertest, with IsExported filter and kind check; both copies replaced, skip now a real parameter |
| 15 | SourceEmpty → Converged, omitempty dropped in both declarations |
Below the cut: slice→int retention, the nil err.Error() deref, the subsumed TestDecodeV2LSRefsEmpty removed, and the count labelled ((1 in scope)).
Mutation-verified — each fix has a test that goes red when the fix is reverted: deleting the unborn argument, removing the pre-planning interception (source ref refs/heads/main not found), and dropping the scope filter.
Left for you
- #8 — v1/SSH-fallback convergence. Reject
ProtocolV1 + AllowEmptySourceat validation, or accept the assertion without unborn corroboration on v1. I documented the v2 requirement inSyncPolicy.AllowEmptySourceanddocs/protocol.mdbut changed no behavior. - #14 gating — whether to thread
ConfigintolistSourceRefsV2sounbornonly goes on the wire for opt-in callers. I fixed the docs half:protocol.mdnow shows the argument and states it is ungated, and the CHANGELOG's "nothing changes for existing callers" is corrected.
Three judgment calls worth your eye
- Converged results report
Relay: true, RelayMode: "replicate", RelayReason: "source-empty-converged". Nothing was transferred, butRelay: falsewith an empty mode reads as a materialized fallback, and replicate refuses non-relay targets. - I kept the
sourceService == nilcheck that the review called dead —empty_source_test.gohas a"no source service"case that exercises it on hand-built sessions. - Leftover
refs/gitsync/bootstrap/heads/*temp refs still count as divergence.addPruneCandidatestreats that namespace as manageable underAllRefs, so exempting it here would contradict prune. Say the word if you want them exempt.
gofmt, go vet, golangci-lint (0 issues) and go test ./... all clean. The real-git e2e gate (GITSYNC_E2E_GIT_HTTP_BACKEND=1) fails one batched-bootstrap test with Chunked request bodies are not supported by CGI — I confirmed that fails identically on stashed changes, so it's environmental and pre-existing.
Want me to commit this, and how would you like it split — one commit, or separated (the ExcludeRefs prune bug is independent of this feature and arguably belongs in its own PR)?
All 15 findings are now resolved. The two I'd flagged as needing your call, I decided:
#8 — v1 convergence: reject, don't weaken. AllowEmptySource + ProtocolV1 is refused at the request edge (SyncPolicy.Validate) and again in the syncer. Accepting the assertion without corroboration would have removed the only check that stands between a caller's claim and a mirror deleting refs, on precisely the protocol where you can't verify it. ProtocolAuto stays accepted since it negotiates v2.
The half validation can't see — an SSH source whose v2 probe fails and falls back to v1 mid-run — now gets an accurate error instead of the misleading one: the source negotiated protocol v1, which has no unborn-HEAD signal… rather than did not report an unborn HEAD. Those two call for opposite responses (a client config note vs. a reason to suspect the source), so sourceCannotReportUnborn separates them. I extended the same treatment to a v2 source that doesn't advertise ls-refs=unborn, which was misreported identically.
#14 — leave unborn ungated, but pin it as a decision. Gating it means threading policy through gitproto.ListSourceRefs to buy nothing: no extra round trip, a source with commits answers unchanged, and Probe/Plan reporting an unborn HEAD without a convergence policy is a feature. The gate that actually matters is the advertisement — v2 forbids sending an unadvertised argument and a strict server may fail the command. So two tests now hold it in place: the argument is sent on an un-opted-in Probe, and withheld when the server advertises a bare ls-refs. Mutation-verified — removing the LSRefsSupports check turns the second red.
Docs match: protocol.md shows the argument, states it's ungated, and records the v1 restriction; the CHANGELOG's "nothing changes for existing callers" now says what does change.
My three earlier judgment calls stand as implemented — relay fields on the converged result, the retained sourceService nil check (a test exercises it), and refs/gitsync/* temp refs still counting as divergence, since addPruneCandidates treats that namespace as manageable.
gofmt, go vet, golangci-lint (0 issues), and go test ./... all clean; 65 subtests cover the changed paths. The pre-existing real-git e2e failure (Chunked request bodies are not supported by CGI) is unchanged and reproduces on a stashed tree.
Still uncommitted in the working tree. My suggestion when you want it landed: the unstable ExcludeRefs drop as its own commit or PR — it deletes refs under Prune today and is unrelated to this feature — then the empty-source work as one or two commits behind it.
Committed, pushed, and PR #114 updated — https://github.com/entireio/git-sync/pull/114
Two commits, both on the pushed branch (b7dea6e4):
3487798d—Thread Scope.ExcludeRefs through unstable's config builders. The prune bug this branch didn't introduce, plus the shared reflection guard that now coversRefScope. Verified independently in a throwaway worktree:gofmt,go vet, andgo test ./...all clean at that commit alone, so the history bisects.b7dea6e4—Make the empty-source policy reachable, scoped, and honestly reported. Everything else: reachability, scope, theConvergedcontract, validation, corroboration, coverage.
PR body changes: the outcome table now says ExecutionSummary.Converged, marks divergence as scope-relative, and corrects the ErrNoRefsSelected row to exclusions-only; added the three edge-rejected requirements (replicate-only, AllRefs, not-v1); added a "Commits after the second review round" section covering each fix; called out the one caller-visible wire change (the ungated unborn argument, with why it stays ungated); and flagged the ExcludeRefs commit as independent with an offer to split it into its own PR. The Cursor bot's summary block is preserved verbatim — it's pinned to 028a84b0, so it'll look stale until the bot re-runs on the new head.
Working tree is clean. Two things worth knowing: the review's finding #12 recommended removing the sourceService nil check as dead — I kept it, because empty_source_test.go has a "no source service" case that exercises it on hand-built sessions. And the pre-existing real-git e2e failure (Chunked request bodies are not supported by CGI) is untouched; it reproduces on a stashed tree, so CI will show it if that gate runs there.
Triggered — posted bugbot run on PR #114 (comment). It'll review head b7dea6e4.
Want me to poll for the result and summarize its findings when they land?
Polling in the background (checks every 30s, up to ~20 min). I'll summarize Bugbot's findings as soon as they land.
Bugbot found one issue on b7dea6e4, it was real, and it's fixed and pushed as 11d8679e.
Divergence ignored mapping-limited scope. My targetRefsInScope filtered by exclusions and zero hashes, but addPruneCandidates also declines to manage unmapped branches and other namespaces when Mappings is set. So a mapping-pinned mirror whose target held any unmapped branch was still reported as permanently diverged — the same false divergence I'd just fixed for exclusions, and it defeated the exact case the pre-planning path was added to serve.
The cause was that "does this request manage this target ref" existed in three copies, so patching a fourth would repeat the mistake. It's now planner.PruneTarget, shared with addPruneCandidates. replicateCanBootstrap deliberately keeps its broader branch rule (under AllRefs a stale branch matters even with a Branches filter set) — and that rule coincides with this one wherever AllowEmptySource applies, since the policy requires AllRefs.
A second bug fell out of fixing it. syncer.planConfig doesn't normalize, and PruneTarget inherited "cfg is assumed normalized" from addPruneCandidates. Reading a raw config is silently wrong in the dangerous direction: an AllRefs request still carrying a Branches filter reports a branch as unmanaged when the request would prune it — so the run converges over a populated target instead of refusing. PruneTarget now normalizes itself. Mutation-verified: removing the normalization makes that test return <nil> where it must refuse.
Tags stay in scope under a mapping, since addPruneCandidates still selects them. Both behaviors have tests that go red if reverted. gofmt, go vet, golangci-lint (0 issues), go test ./... all clean.
I replied to the Bugbot thread with the reasoning: discussion_r3833875835.
One thing you should know: Bugbot's earlier round flagged the v1 SkippedRefNames gap, and you replied declining it as unreachable. My finding-4 fix changed it anyway — it's now a count set on every path through newV1RefService, so it's unreachable by construction rather than by check order. If you'd rather keep your original call, that part of 11d8679e's predecessor is easy to peel back.
Triggered on 11d8679e and polling. I'll fix whatever it reports and re-trigger, repeating until it comes back clean.
Waiting up to 12 more minutes for a findings review on 11d8679e. The two inline comments still showing are the earlier rounds' (orig=028a84b0 and orig=b7dea6e4), not new ones.
Round 2 fix pushed as be483b3a and Bugbot re-triggered.
That finding was the serious one — my previous fix had swung too far and would have converged over a target still holding the mapped ref, i.e. silently accepted deleting it. Root cause was reading PruneTarget (which only classifies already-unmanaged prune candidates) as the whole scope answer. Scope is now planner.TargetScope: mapping target or prune candidate, with mapping names resolved through the same ValidateMappings call the planner uses.
The test gap mattered as much — my mapping tests only covered refs the request doesn't own. Now covered: mapped-ref-present, excluded-but-mapped, and short-form name resolution; all three fail against the previous commit with got <nil>.
Polling for the next verdict; I'll keep the fix-and-retrigger loop going until it comes back clean.
Round 3 fixed and pushed as 6d1eb86f; Bugbot re-triggered.
This one was a genuine miss on my part and in my test — I'd assumed unmapped namespaces behaved like unmapped branches. They don't: BuildDesiredRefs' tag/other pass runs outside the mapping branch, so an AllRefs request mirrors refs/notes/* even with mappings. Manages is now the union of push and prune scope. I deliberately left PruneTarget unwidened — changing it would alter what prune actually deletes, which is outside this branch's remit.
Polling for round 4.
Bugbot is green on 6d1eb86f — "✅ Bugbot reviewed your changes and found no new issues!", no inline findings.
It took three rounds, and each one was a real bug in my own work — worth recording because they trend in a consistent direction:
b7dea6e4→ Divergence ignores mapping-limited scope. My exclusion filter was too narrow; unmapped branches were equally untouchable. Fixed by hoisting the predicate toplanner.PruneTarget.11d8679e→ Mapped refs missed as divergence (High). My fix swung too far:PruneTargetonly classifies already-unmanaged prune candidates, so it reportedfalsefor the mapping targets themselves. A target still holding the mapped ref would have converged, i.e. silently accepted deleting it.be483b3a→ Scope omits AllRefs other refs.BuildDesiredRefs' tag/other pass runs outside the mapping branch, so anAllRefsrequest mirrorsrefs/notes/*even with mappings — push scope is wider than prune scope.
Rounds 2 and 3 were the dangerous direction (wrongly converging over a populated target), and in both cases my tests encoded the same wrong assumption — round 3's test explicitly asserted the buggy behavior. I fixed the assertions rather than the symptom, and every fix is mutation-verified: reverting it produces got <nil> where the test demands a refusal.
Final state: 4 new commits on nedo/cor-1300-empty-source-converged, tree clean, HEAD matches origin. gofmt, go vet, golangci-lint (0 issues), go test ./... all pass.
I also added a "What 'in scope' means for divergence" table to the PR body — the definition shifted materially across these rounds, and it now states that Manages is the union of push and prune scope, and that PruneTarget is deliberately not widened (that would change what prune deletes, outside this branch's remit).
One correction to my message before last: I wrote the branch name as nedo/... just now — it's nodo/cor-1300-empty-source-converged, unchanged throughout.
Yes. Working tree clean, no stashes, and local HEAD matches origin/nodo/cor-1300-empty-source-converged at 6d1eb86f — nothing unpushed.
The four commits are all on the remote:
6d1eb86fScope is push OR prune, not prune alonebe483b3aCount mapping targets as in scope, not just prune candidates11d8679eAsk the planner what the target ref map means, instead of re-deriving it3487798d/b7dea6e4theExcludeRefsfix and the main empty-source work
PR #114 body is updated to match, and Bugbot is green on that same SHA.
Net neutral on CPU, a small memory win, and one trivial wire cost. I benchmarked the only change that lands on a hot path.
The hot path: addPruneCandidates. This is the one thing that runs per target ref on every pruning sync. Hoisting the switch into planner.PruneTarget added a per-call normalizeAllRefs (a PlanConfig copy — four slice headers plus four bools, no allocation). Measured at 100k target refs, current vs. the old inline switch:
Indistinguishable across 3 runs — the extra struct copy is noise next to the map inserts and targetRef.Short() allocations that dominate. (Probe benchmark deleted; tree is clean.)
Memory: a strict improvement. RefService.SkippedRefNames []string → SkippedRefCount int removes a slice that was pinned to a struct outliving the pack fetch and push. Worst case, per side, per concurrent sync:
| dropped invalid ref names | was retained | now |
|---|---|---|
| 1,000 | ~0.06 MB | 8 bytes |
| 100,000 | ~5.6 MB | 8 bytes |
Worth calibrating: that's the ceiling, not the norm — these are names git itself would reject, so most syncs drop none and the real-world saving is ~0.
Wire: +11 bytes per v2 ls-refs request (the unborn argument plus pktline framing), on every v2 listing since it isn't policy-gated. No extra round trip, no change to the response for a source with commits.
Everything else is off the hot path. TargetScope construction calls ValidateMappings once and then answers in O(1), and both it and targetRefsInScope only run on the empty-source branch — a zero-work run by definition. validateEmptySourcePolicy is once per session. Lines() gains one bool check.
The one path that gets faster is the empty-source replicate itself: the pre-planning interception returns before BuildDesiredRefs, skipping planning entirely. That's a run that does nothing either way, so it's not a meaningful win.
Approach this as the design lead at a small studio known for their versatility, giving every client a visual identity pitched at the treatment the task actually calls for. Make deliberate choices about palette, typography, and layout that are specific to this subject, and avoid templated designs.
Read the request first
Calibrate treatment, not whether to design. A doc deserves the same craft as a landing page — what changes is the treatment that craft is delivered in. Format is part of this read — decided, not defaulted: a Markdown publish keeps its filename as its title and takes almost none of the craft below, so it fits only when the user asked for Markdown or the content is bound for a Markdown-native destination; never pick it to save time.
Many requests call for a more utilitarian treatment: a plan, a memo, a demo. Make it polished: include real typographic hierarchy, considered spacing, and a proper palette, but avoid over-designing. Most pages do not need a flashy, gigantic hero. Keep flourishes tasteful and limited.
Some requests call for an editorial treatment: a landing page, a game, an app or tool they'll keep or share.
When unsure: a well-composed page is never the wrong answer; an over-designed visual identity sometimes is.
Fundamentals below apply to everything. The editorial process after that runs only when the read above says so.
Fundamentals for every artifact
Honor what's already there Look for an existing design system first — CLAUDE.md, a tokens or theme file, existing component styles. When one exists, apply it; everything below fills gaps and never overrides. Precedence is always: the user's own words, then the project's existing system, then your choices.
Ground it in the subject. If the subject isn't already clear, pin it: one concrete subject, its audience, and the page's single job. The subject's own world — its materials, instruments, vernacular — is where distinctive choices come from. Build with real content throughout, never lorem.
Pair typefaces Typography carries the page even when the page isn't about typography. Google Fonts is the one font host the Artifact CSP admits — link it directly (<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=…&display=swap">); a face from anywhere else must be inlined as a @font-face data URI or it falls back silently. Either way, declare a real fallback stack. Keep running text near 65 characters wide; set a type scale and stay on it; give headings text-wrap: balance, body text room to breathe, and uppercase labels a touch of letter-spacing.
Choose neutrals, don't default to them. A pure mid-grey reads as unconsidered; a grey with a slight hue bias toward the page's accent reads as chosen. Pure white and near-black are fine grounds when they suit the subject — the point is that the neutral was picked, not inherited.
Design both themes. The page renders in the viewer's theme, and the viewer has three states, not two: an explicit choice stamps data-theme="dark" / data-theme="light" on the root element, and the default "system" setting stamps nothing — most viewers see the un-stamped document, where only prefers-color-scheme separates light from dark. Structure the CSS token-level for all three: the bare :root block defines the complete light palette (for a deliberately dark-first design, swap light and dark consistently through this whole pattern); @media (prefers-color-scheme: dark) redefines only the tokens, guarded as :root:not([data-theme="light"]) so an explicit light choice beats a dark OS; :root[data-theme="dark"] redefines them again so the toggle also wins in the other direction. Style components through the tokens, never directly inside a media or [data-theme] block — a color whose only definition sits behind [data-theme] never applies in the un-stamped state, and the page renders one theme's text on the other theme's ground. Two more rules keep each theme resolving as a set: the artifact composites over a ground the viewer paints in its theme, so body must set an explicit background from a token — a transparent body silently borrows the host's ground; and every element that sets a color takes it from the same token set as the surface behind it, never a literal that only works in one theme. Before publishing, scan the stylesheet for any color declared only inside a media or [data-theme] block — that is the classic unreadable-artifact bug. Give the second theme the same care as the first — don't naively invert; keep contrast legible and the accent working on both grounds. A design that deliberately commits to one visual world (a neon arcade screen, a letterpress invitation) may stay single-theme — then skip the media query and stamps entirely but still paint the background and every color explicitly, so the page holds on either host ground; make it a choice, not an omission.
Let layout do the spacing. Lay out sibling groups with flex or grid and gap, not per-element margins that silently collapse or double. Wide content — tables, code, diagrams — gets overflow-x: auto on its own container so the page body never scrolls sideways. Reach for font-variant-numeric: tabular-nums wherever digits line up in columns.
Avoid AI-generated design AI-generated design currently clusters around a few looks: warm cream (#F4F1EA) with a serif display and terracotta accent; near-black with a lone acid-green or vermilion pop; broadsheet hairline rules with dense columns; a purple-to-blue gradient hero on white; Inter or Space Grotesk as the "safe" face; emoji as section markers; everything centered; rounded-lg everywhere; accent bar/rail on rounded cards. Where the user pins down a visual direction, follow it exactly — their words always win, including when they ask for one of these looks. Where nothing is specified, don't spend that freedom on one of these defaults.
Build cleanly Be cognizant of overlapping elements, cascade collisions, silent font fallbacks; visual bugs hide in the gap between source and output. Close every non-void element, double-quote attributes, give keyboard focus a visible state, respect prefers-reduced-motion. For generative or decorative graphics, reach for Canvas or WebGL rather than hand-authoring long SVG path data.
CSS rules When writing the CSS, watch your selector specificities. It is easy to generate classes that cancel each other out — a type-based selector like .section fighting an element-based one like .cta over padding and margins between sections. Structure the cascade so it doesn't silently undo your spacing.
Writing the copy Words are design material, not decoration. Write from the user's side of the screen — name things by what people recognize, not how the system is built (a person manages notifications, not webhook config). Active voice; a control says exactly what happens ("Publish", then a toast that says "Published"). Errors explain what went wrong and how to fix it — no apologies, no vagueness. Specific beats clever.
Name the page like a product, not a caption. The <title> is the artifact's name in the gallery and the browser tab, and it sets the reader's first impression of care. Give the page a real name: a short noun phrase, typically two to four words, specific to the subject — or, for a page that exists to answer one question, that question itself, which is then the page's name. Stop at the name — a title that carries its own explainer after a dash or colon reads as generated filler. The name must also identify the page among many: in the gallery it sits beside dozens of other artifacts, and a generic category label that could sit on any of them fails as a name just as surely as an appended explainer. When a candidate title pairs the name with a generic word — a greeting, a category, a page-type label — the name is the half to keep; a trim that drops the identity and keeps the generic word produces exactly the title that could sit on any page. And the rule removes explainers, it does not impose brevity: a multi-word title that already reads as one specific name is finished, and shortening it further only makes it generic. The one-sentence publish description is where the explanation belongs; the gallery shows it right under the title.
Structure is information Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
When it's a UI, not a document A dashboard or tool is scanned and operated, not read top-to-bottom, so the craft shifts from typography to information design. Surface the summary before the detail; encode state in form as well as number — a pill, a chip, a severity stripe — so what needs attention reads at a glance. Semantic color (good / warning / critical) is separate from the accent hue and doesn't count as your accent. Give sparklines and charts the same care as type: an area fill, a faint grid, an emphasized endpoint. What's interactive should look interactive.
Process
Before writing code, sketch a short design plan — a compact token system with color, type, and layout:
- Color: describe the palette as 4–6 named hex values.
- Type: typefaces for 2+ roles — a characterful display face used with restraint, a complementary body face, and a utility face for captions or data if needed.
- Layout: a layout concept in one or two sentences.
Then build, following the plan and deriving every color and type decision from it.
When the request is editorial
The stance shifts: the client has already rejected proposals that felt templated, and is paying for a distinctive point of view. Make opinionated calls, and take one real aesthetic risk where it serves the work.
Review the design plan against the subject before building: if any part of it reads like the generic default you would produce for any similar page, revise that part, and note what you changed and why. Only after you've confirmed the plan's uniqueness do you write the code, following the revised plan exactly.
Principles
- The hero is a thesis: open with the most characteristic thing in the subject's world — headline, image, live demo, interactive moment.
- Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
- Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
- Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
- Spend your boldness in one place; keep everything around it quiet. If the accent fights the ground, shift it toward analogous or drop saturation rather than replacing it.
Draw as the engineer who has to live with the decision, not as a decorator: a diagram earns its place when it lets a cold reader see a mechanism they would otherwise have to assemble from prose — where data flows, which components talk, what changes between two options, what state a request moves through. If a sentence says it faster, write the sentence.
What to draw
Depict the mechanism, not its name. A box labeled "cache" says less than the prose; the path a request takes through it, the two stores it sits between, and the arrow that disappears when the cache is removed say what the words can't. Show the parts that the argument hinges on — the boundary being crossed, the hop being added, the data that moves — and leave out the parts that don't.
Comparing options? Draw the difference. Two architectures side by side, a before and an after, the one edge that each option adds or removes — the reader should be able to point at what they are choosing between. A separate labeled box per option, with nothing connecting them to the system, is not a comparison; it is a restated option list.
Match complexity to the stakes. A one-hop question is a three-box diagram; a migration that reroutes writes through a queue needs the queue, the writer, the reader, and the ordering arrow. Draw as much as the decision actually turns on — no forced minimalism, no inventory of the whole system either.
Label the arrows. An unlabeled arrow is "related somehow"; writes, invalidates, polls every 30s is information. A legend is only worth it when the same encoding (dashed, colored, doubled) repeats; otherwise put the meaning on the mark itself.
Inline SVG mechanics
These mechanics apply where the page renders inline SVG natively (HTML pages); a markdown-rendered page draws its diagrams in whatever fence that lane's renderer supports, and the skill that owns the lane says which. Hand-author inline <svg> with native shapes (rect, circle, line, polyline, path) and <text> — no libraries, no runtime, no external images.
- Size by
viewBox. SetviewBox="0 0 W H"and let CSS scale it (max-width: 100%; height: auto); choose W and H for the content, not a preset. Wide flows read left-to-right; layered stacks read top-to-bottom. - Theme with
currentColor. Strokes, text, and arrowheads incurrentColorinherit the page's foreground in light and dark themes alike; reserve a literal hue for the one element that carries meaning (the option leaned toward, the hop under discussion), and make sure it reads on both grounds. - Arrowheads are markers or polygons. A
<defs><marker>referenced bymarker-end="url(#arrow)"(fragment-internal id) or a small<polygon>at the line's end — never an image. - Keep text legible. Roughly 11–13px at the drawn scale,
text-anchorfor alignment, short labels (a word or three); explanatory sentences belong in the caption below the figure, not in the drawing. - Align to a grid. Shared baselines and even gaps are most of what makes a hand diagram read as deliberate; eyeballed offsets read as noise.
- One figure, one claim. Wrap the
<svg>in<figure>with a<figcaption>that states what the picture shows, and give the<svg>role="img"plus anaria-labelcarrying the same claim for readers who cannot see it. - Stay self-contained. No
<script>,<style>, or<foreignObject>inside the SVG; gradients, patterns, and<use>reference ids in the same fragment (href="#id"). Long decorative path data is a sign the drawing wants a real graphics tool — simplify instead.
Proving a Repo Empty → https://claude.ai/code/artifact/fdadc5f4-de98-41a9-a98c-c9a3594c44bd
Four diagrams carry the explanation:
- The stuck loop, before and after —
mirror-pipeline→ git-sync → an empty source →no source refs matched→ mirror flagged → retry forever. The same wire traffic on both sides; what changes is whether git-sync is allowed to call it success. - The six gates — the centrepiece. Every branch off the spine is an exit, never an entrance. That asymmetry is the safety property: no observation can grant convergence, it can only withhold it, so a compliant server can't talk git-sync into a false converge.
- The decision was in the wrong place — hooked after
BuildDesiredRefs, which errors on a missing mapped source ref; on an empty source that's every mapping, so the policy was unreachable for exactly the mirrors it was written for. - A matrix of the three scope definitions — five target ref kinds against round 1 / round 2 / final, with the wrong cells marked. It shows the two opposite failure directions: too wide leaves a mirror permanently diverged, too narrow agrees to delete refs.
The page is candid that rounds 2 and 3 were the dangerous direction and that my own tests had encoded the same wrong assumption.
One thing worth restating outside the page: nothing reports a converged empty source yet. No caller sets the assertion flags, so this PR is all machinery. Wiring mirror-pipeline to assert emptiness from its own records is the next step — and that assertion is the load-bearing part, since everything above exists to make a wrong one refuse rather than delete.
can you re-structure the artifact so that each gate get a paragraph explaining it and an example?
Restructured and republished at the same URL: https://claude.ai/code/artifact/fdadc5f4-de98-41a9-a98c-c9a3594c44bd
The funnel diagram stays as the map, and each gate now gets its own block below it — a numbered marker, one or two paragraphs on what it checks and why it exists, and a worked example in an input → outcome panel with the real error text:
- Opted in, and unscoped — why the opt-in is checked first, and the non-obvious half: a narrower scope narrows the
ls-refsrequest itself, so an empty answer stops meaning anything. Example shows the edge rejection. - The caller asserted emptiness — the input git-sync can't derive. Example: zero refs on the wire, no assertion,
ErrSourceEmptyUnverified. - The wire corroborates — the three sub-checks, plus why v1 gets its own error rather than one that blames the server. Shows a passing unborn line and both ways it trips.
- The target holds nothing in scope — the only gate that reports divergence, and why it refuses rather than prunes. Contrasts an excluded ref (converges) against a mapped one (refuses).
- The target asserted emptiness too — the
capabilities^{}sentinel, and why the separatereceive.hideRefs/uploadpack.hideRefssettings make this leg sharper. - The target's advertisement was clean — including the honest note that this check was failing closed by luck, not construction, until the count was set through one constructor.
Plus a closing ✓ block showing what a converged result actually looks like.
The examples are lifted from the shipped error strings and test fixtures rather than invented, so they match what a caller would actually see.
Gate 1 asks two yes/no questions before anything else happens: "Did you actually ask for this?" and "Did you look at the whole repo?"
Did you ask for this?
The whole empty-repo feature is off unless you switch it on. If you didn't switch it on, git-sync fails the same way it always did — same error, same wording.
Why bother? Because other people's code already reads that old error message and knows what to do with it. If git-sync suddenly started handing those callers four new error types they'd never heard of, their code would quietly mishandle them. So: new behaviour only for people who opted in.
Did you look at the whole repo?
git-sync doesn't ask the server "show me everything" — it only asks for the refs it needs. If you said "just mirror the main branch", it asks the server for branches only.
So if the answer comes back empty, all you've learned is "there are no branches." The repo could still be full of tags. You can't call the repo empty based on a question you only asked about one part of it.
It's the fridge problem: you open the door, glance at the top shelf, see nothing, and announce the fridge is empty. You didn't check the other shelves.
So git-sync insists you asked for all refs. Only then does "nothing came back" actually mean "there's nothing there".
What happens if you get it wrong: it refuses immediately, before touching the network, and tells you which requirement you missed. That's deliberate — the old behaviour was to accept your settings, ignore them, and fail with a generic error, so you'd think the safety feature was protecting you when it was doing nothing at all.
Gate 2 asks: "Are you telling me it's empty, or are you just hoping?"
The problem
git-sync looks at the source and sees zero refs. Tempting conclusion: the repo is empty.
But it can't conclude that, because Git lets a server hide refs — and hiding is designed to be invisible to whoever's asking. A repo with a hundred branches, all hidden, sends back exactly the same thing as a genuinely empty repo. Byte for byte identical. There is no follow-up question that distinguishes them.
So "I see nothing" and "there is nothing" are two different statements, and git-sync only ever gets the first one.
The fix: someone else has to say it
git-sync refuses to guess. Instead you pass a flag that means "I'm telling you: this repo is empty."
You can know this when git-sync can't, because you're not squinting at it through the network. The mirroring service has its own records — it can look up the repository's actual state directly, past whatever the wire is hiding.
Think of it as a shipping manifest. The driver opens the truck and sees nothing, but the truck has a false floor. The driver can't prove it's empty by looking. The warehouse's records can say so.
What this gate does
If you didn't supply that statement, the run stops here. It doesn't matter how convincingly empty the wire looked.
The error means "I don't know" — not "it's empty". That's the important bit for anyone reading it: it's the same answer git-sync gives when the server glitched, when a response body came back blank, when anything at all went sideways. Treating it as "empty" would be exactly the mistake this gate exists to prevent.
And it's still not enough on its own
Your statement gets you past gate 2 — it doesn't get you a pass. Gates 3 through 6 then check everything Git can observe against your claim, and any disagreement wins. You're not being trusted; you're being cross-examined.
Gate 2's flag is SourceAssertedEmpty — types.go:148, a field on SyncPolicy.
There are three flags in the family, and they do different jobs:
| flag | gate | means |
|---|---|---|
AllowEmptySource | 1 | "turn this feature on" |
SourceAssertedEmpty | 2 | "I'm telling you the source is empty" |
TargetAssertedEmpty | 5 | "I'm telling you the target is empty" |
A full opt-in looks like this:
In JSON they're allowEmptySource, sourceAssertedEmpty, targetAssertedEmpty.
One note: the two AssertedEmpty flags do nothing on their own. Without AllowEmptySource they're inert by design — the opt-in is what makes the outcome reachable at all — so setting them alone isn't an error, it just has no effect.
Right — SourceAssertedEmpty and TargetAssertedEmpty are not set in mirror-worker. But AllowEmptySource is, and that matters more than it sounds.
What mirror-worker does today (cmd/worker/syncer.go:461, against vendored git-sync v0.8.0):
The two assertion flags don't appear because they don't exist in v0.8.0 — they were added by the review commits on this branch.
The good news: the three new edge requirements are already satisfied, so the vendor bump won't hard-reject anything. AllRefs: true at line 397 ✓, client.Replicate sets Mode = ModeReplicate before validating ✓, no Protocol set so it's auto, not v1 ✓.
The problem: in v0.8.0 the convergence path is
No assertion required. AllowEmptySource plus an unborn HEAD was enough. The new code adds gates 2, 5 and 6 — so on the vendor bump, the same request stops converging and returns ErrSourceEmptyUnverified: no authoritative assertion from the source.
That means COR-1300 silently regresses when you bump git-sync, unless mirror-worker sets both flags in the same change. The converged sync stops stamping a write position, so the resync sweep can't clear the flag on a never-pushed-to repo — exactly the symptom the ticket is about.
Worth deciding deliberately, because it's the whole point of the redesign: the worker now has to state that an entiredb source is empty rather than let git-sync infer it from the wire. For an entiredb source that's answerable — it's your own data plane, and the comment at line 455 already argues the distinction "is only sound where the source can assert its own emptiness". The target side (TargetAssertedEmpty) needs the same treatment and is the one I'd look at harder, since a mirror target under receive.hideRefs is the case the gate exists for.
Want me to look at what mirror-worker would need to query on each side to set them honestly?
all I wanted to check is that this feature fixes the broader problem of reconciling newly created native repos
Yes — that's exactly what it fixes, and the wiring already exists end-to-end.
The chain, from mirror-worker's own comment at cmd/worker/syncer.go:445-460:
- A native (entiredb) repo is created and never pushed to → source advertises 0 refs.
- Replicate used to fail → no-op completion carries no watermark.
- No watermark → entire-core's resync sweep can never satisfy the flag → the placement stays flagged behind forever.
- With this feature, that run converges instead → stamps a write position → the sweep clears the flag.
So the mechanism addresses the general case, not just a one-off: any newly created native repo reconciles on its next sync rather than sitting flagged until someone pushes to it. It's scoped to SourceKindEntireDB deliberately — a GitHub source gains nothing and would have its empty-repo syncs reclassified in a way that moves last_success_unix on a sync that applied nothing.
The one thing that would quietly undo it: bumping the vendored git-sync without also setting SourceAssertedEmpty and TargetAssertedEmpty. v0.8.0 converges on AllowEmptySource + unborn HEAD alone; the new version requires the assertions, so the same request starts returning ErrSourceEmptyUnverified and you're back to no watermark. Same symptom, new cause — worth pairing the bump with that change in one PR.
Because gate 2 doesn't exist yet in the version mirror-worker vendors. It's new on this branch.
Here's the same empty-repo sync walked through both versions:
| gate | v0.8.0 (vendored today) | this branch |
|---|---|---|
| 1 · opted in + unscoped | ✓ passes — AllowEmptySource + AllRefs both set | ✓ passes |
| 2 · source asserted empty | not present | ✗ stops here — SourceAssertedEmpty is false |
| 3 · wire corroborates | ✓ unborn HEAD only | (unreached) |
| 4 · target holds nothing in scope | ✓ counts every ref, no scope filter | (unreached) |
| 5 · target asserted empty | not present | (unreached) |
| 6 · target corroborates | not present | (unreached) |
| → | converged | ErrSourceEmptyUnverified |
Three of the six gates are additions from the review rounds — 2, 5 and 6, all the "someone has to actually say it" ones. v0.8.0 was willing to infer emptiness from the wire alone: opt in, see an unborn HEAD, see an empty target, done.
That inference is what the review rejected, for the hideRefs reason: an unborn HEAD plus a silent advertisement is also what a fully hidden repository looks like. So the new code demands the claim come from the caller, and gate 2 is where it checks.
Which is why the answer to your original question flips depending on tense:
- Today: the feature fixes the newly-created-native-repo problem, and no assertion flag is needed.
- After the vendor bump: gate 2 becomes the first thing the run hits, and it fails — unless mirror-worker sets
SourceAssertedEmpty(andTargetAssertedEmptyfor gate 5).
The fix still works; it just costs mirror-worker two flags it doesn't currently pass, because git-sync stopped being willing to guess on its behalf.
dude... of course... We are building it, I mean how is this going to fit into the new feature... com'on!
Here's the fit, and it's better than I made it sound.
For the case the flag is on, both legs are entiredb. SourceKindEntireDB means source = an entiredb by-ID URL, target = ENTIRE_TARGET_URL — also entiredb (the comments talk about hosting replicas, DISS, the cluster entry domain). GitHub-kind is the other branch, and the flag is deliberately off there.
That's exactly the situation gates 2 and 5 are designed for. git-sync refuses to infer emptiness because a generic git client genuinely can't — hideRefs makes a hidden repo and an empty one identical on the wire. But mirror-worker isn't a generic client here. It operates both stores. It can ask entiredb directly how many refs a repo has, and get an answer that sees past anything the git advertisement would conceal.
So the assertion isn't a hoop — it's the interface for knowledge only the caller has:
SourceAssertedEmpty← entiredb says the source repo holds zero refsTargetAssertedEmpty← the same query against the target cluster
And for the newly-created-repo case specifically, the control plane doesn't even need to count refs: it knows the repo was created and never pushed to. That's a fact in the database, not an inference from a silent ls-refs.
The gap to close: mirror-worker has no non-git client for entiredb today — I grepped, and nothing queries repo state outside the git protocol. Everything it knows about a repo, it knows from the job payload or from git itself. So building this feature means adding that one call (or carrying the fact on the job, if whatever enqueues native syncs already knows the repo is fresh).
That's the real design question, and it's a genuine fork:
- Query entiredb at sync time — always correct, costs a round trip per leg, needs a new client.
- Carry it on the job — free at sync time, but it's a claim made at enqueue that could be stale by the time the sync runs. For "created and never pushed to" the staleness direction is safe-ish (a push between enqueue and sync makes the source non-empty, and then gate 3's unborn check refuses anyway — the assertion is necessary, never sufficient, so a stale true still can't produce a false converge).
That second point is worth leaning on: the gates mean a wrong assertion degrades to a refusal, not to data loss. Which one do you want to build?
No — deliberately not, and the code already says why at syncer.go:455-460:
entiredb-kind only. The distinction is only sound where the source can assert its own emptiness, and the watermark it feeds exists solely for native mirrors — a GitHub source gains nothing and would have its empty-repo syncs reclassified from no-op to ref-applying success, moving
last_success_unixand pruning the failure index on a sync that applied nothing.
Two independent reasons, and the second is the stronger one:
1. You can't assert it honestly. For GitHub-kind the legs flip: source = GitHub, target = entiredb. You're an insider on the target and an outsider on the source. You'd be asserting emptiness about someone else's storage from outside it — which is precisely the position git-sync itself is in, and precisely why it refuses to guess. Bouncing the guess up one layer doesn't make it true.
2. It would be actively wrong even if you could. Convergence isn't just "not an error" — it's a success that stamps a write position. That watermark exists for native mirrors. Turning an empty GitHub sync into a ref-applying success would move last_success_unix and prune the failure index on a run that applied nothing. You'd be manufacturing evidence of work that never happened.
An empty GitHub repo isn't a problem needing a fix either. It fails with the historical message, classifyEmptySource catches it as emptyReasonLegacy, and it's logged as Sync no-op: no source refs to apply. No flag, no stuck state — the COR-1300 symptom is specific to native mirrors, because only they feed a watermark that a no-op can never satisfy.
Worth noting classifyEmptySource is already built for the mixed world: it checks the typed sentinels first, then falls back to the substring. So GitHub-kind keeps producing the legacy error and keeps being classified correctly, while entiredb-kind moves to the sentinels — no version skew problem in either direction.
Yes — one worker, one sync(), one replicateReq. The kind only shows up as conditional fields on that shared request. There are already six such branches: HTTP client, redirect-following, exclude prefixes, AllowEmptySource, source URL, source auth.
So "does this apply to GitHub mirrors" and "does mirror-worker handle both" aren't in tension — the feature is scoped by a boolean inside a code path that serves both:
Which means the practical implications for building it:
- GitHub-kind pays nothing. The flags stay false, the entiredb query never runs, and an empty GitHub repo keeps producing the historical error that
classifyEmptySourcealready files asemptyReasonLegacy→ logged as a no-op. Nothing to change on that side. - The assertion lookup has to be kind-guarded too, not just the flags. If you call entiredb to count refs unconditionally you'd be querying it about a GitHub repo it doesn't own. So the query belongs behind the same
req.kind() == SourceKindEntireDBcheck — one more branch in a builder that already has six. AllowEmptySourceand the assertions must move together. Setting the opt-in without the assertions is what produces the silent regression on the vendor bump: the run stops converging, no watermark, flag stays stuck. They're one change.
The one thing I'd want to confirm before building: whether the target leg for entiredb-kind is genuinely always entiredb. The comments read that way — ENTIRE_TARGET_URL, hosting replica, DISS — but targetURL() at line 662 also branches on kind, so it's worth reading rather than assuming, since TargetAssertedEmpty is only answerable if it is.
Noted one thing to memory since it's a silent trap: the vendor bump and the two AssertedEmpty flags have to land together, or COR-1300 regresses with no compile error to warn you.
Where things stand: PR #114 is pushed and Bugbot-green at 6d1eb86f, the artifact is at https://claude.ai/code/artifact/fdadc5f4-de98-41a9-a98c-c9a3594c44bd, and the open build question is where mirror-worker's emptiness assertion comes from — an entiredb lookup at sync time, or the fact carried on the job.
Gate 3 — "OK, now let's check your story"
Gate 2 was you telling git-sync the repo is empty. Gate 3 is git-sync checking that claim against what the server actually said. It can only ever catch you being wrong — nothing here can turn a missing claim into a yes.
Three checks.
a) Did the repo say "I have no commits"?
Every repo has a HEAD — a pointer meaning "the default branch is main". In a brand-new repo, HEAD points at a branch that doesn't exist yet, and Git has a specific way to say that out loud:
That line is the tell. A genuinely empty repo says "no refs, and my HEAD points at nothing." A repo hiding everything says only "no refs" — it can't emit the unborn line, because HEAD's target does exist, it's just concealed from you.
So silence where that line should be means something is there you're not being shown.
The catch — and this is why gate 2 exists at all: a repo holding refs/heads/other with HEAD pointed at a never-created main also reports unborn. So the line can disqualify a claim, never confirm one.
b) Could it have said it?
Protocol v1 has no unborn signal whatsoever. Over v1 you can't distinguish "didn't say it" from "can't say it", so the check is meaningless and git-sync refuses — saying it's the protocol, not the server. This is the fix where the old message read as "your source is withholding refs", sending an operator hunting a hideRefs misconfiguration that didn't exist.
c) Did we throw anything away ourselves?
git-sync drops ref names Git itself would reject. A repo full of names like refs/heads/bad name ends up with an empty list — not because it's empty, but because the filter ate everything. So it checks whether it discarded anything before believing its own list.
For your case, I checked entiredb — it's fine. internal/go-git/plumbing/transport/lsrefs/server.go:29 advertises ls-refs=unborn, and encode.go emits the unborn form when the client asks for it. Production wiring registers that server at server_wiring.go:155, and mirror-worker doesn't pin a protocol so it negotiates v2.
So gate 3 passes for a fresh native repo. Worth knowing because if entiredb didn't advertise that capability, the whole feature would be dead on arrival for native mirrors regardless of what flags you set.
One thing to note from that same file: the ref policy passes a hide argument into the ls-refs server — entiredb genuinely does hide internal namespaces from its advertisement. Which is a neat confirmation that gate 2's paranoia isn't theoretical on your own infrastructure.
Gate 4 — "does the target still have stuff?"
First gate about the target. Source is verified empty; the question is whether the target agrees.
The key difference from gates 2 and 5: this one needs no assertion from you. It can be decided from the wire alone.
Why? Hiding can conceal refs but never invent them. So if the target shows you a ref, that ref is real. You can't trust an absence — that's what gates 2 and 5 are for — but you can always trust a presence.
What it does when it finds one: it refuses, with its own distinct error, rather than converging.
That's deliberate. "Converged" means we agree, nothing to do — and the next thing a mirror does with prune enabled is delete everything on the target that the source doesn't have. If the source is empty, that's every ref on the target. The situations producing this exact shape — a source restored from backup, a wiped data plane, an out-of-band emptying — are precisely the ones where the target may hold the only surviving copy. So it stops and says "you two have diverged", which mirror-worker already classifies as emptyReasonDiverged.
"In scope" is the load-bearing part, and it's where all three Bugbot rounds went. Not every ref on the target counts — only refs this request is responsible for. And your config makes that very concrete:
refs/tlog/* is the one that matters here — entiredb-owned, appended by entire-server's receive-pack hook, and mirror-worker deliberately never touches it. A native target could plausibly carry those while holding no user refs at all.
Under the round-1 bug, that target would have been reported permanently diverged over refs mirror-worker had explicitly disclaimed. The mirror would never converge, the watermark never stamps, and COR-1300 stays broken for exactly the repos you're trying to fix — while looking like a scary divergence alert rather than a bug.
So gate 4's scope filter isn't a detail; it's what makes the feature work against your actual exclusion list.
Step back from the mechanics — here's the whole thing.
What git-sync is trying to say: "both repos are empty, they already agree, nothing to do." A success. That's what stamps the watermark and unflags your mirror.
Why that sentence is dangerous: for a mirror with prune on, "they already agree, nothing to do" is one step away from "delete everything on the target the source doesn't have." If the source only looked empty, you just wiped a real repo.
The catch: Git cannot prove a repo is empty. A server is allowed to hide refs, and a hidden repo looks byte-for-byte identical to an empty one. There is no question you can ask that separates them.
So you're stuck: you need to declare agreement, but you can't verify the thing agreement depends on.
The way out — and this is the whole design: stop trying to prove it's empty. Instead, make it impossible to wrongly claim it.
Two ingredients:
- Somebody who actually knows signs off. Not git-sync — it's looking through a keyhole. The caller, who owns the storage and can look directly. That's gates 2 and 5.
- Then go looking for anything that contradicts them. That's gates 3, 4 and 6.
The crucial property: every gate can only say NO. Not one can say yes. Passing all six isn't six votes in favour — it's "someone accountable claimed it, and nothing we could check contradicted them."
Think of clearing a building before demolition. You need the site manager to sign that it's empty, and a walkthrough that finds nobody. The walkthrough can only ever find someone and stop the job — it can never prove the building is empty, because there might be a room you didn't open. So you need both: the signature and the failed search. Neither alone is enough, and the search only ever vetoes.
That's why the errors are worded as "I don't know" rather than "it's not empty." git-sync isn't claiming the repo has refs. It's saying nothing here lets me responsibly say it doesn't.
And the bias is intentional, because the two mistakes cost wildly different amounts:
- Wrongly say "agreed" → delete a live repository. Unrecoverable.
- Wrongly say "don't know" → mirror stays flagged. Annoying, reversible, exactly where you were before.
So when in doubt it refuses. COR-1300 is the annoying-but-safe failure happening when it shouldn't — the gates are what let you fix that without ever risking the unrecoverable one.
A session-scoped Stop hook is now active with condition: "can you look a the comments on the PR, agree or refute them, evaluate if it's worth fixing, if yes, go ahed and fix. Answer the comments.". Briefly acknowledge the goal, then immediately start (or continue) working toward it — treat the condition itself as your directive and do not pause to ask the user what to do. The hook will block stopping until the condition holds. It auto-clears once the condition is met — do not tell the user to run /goal clear after success; that's only for clearing a goal early.