Separate an empty source from an unmatched scope, and let a verified-empty source converge - #114
Conversation
…empty source converge Replicate failed every run whose planning produced no desired refs, with one message covering two unrelated conditions: the source has no refs, and the source has refs that the requested scope excluded. A caller could not tell them apart, and the first is not always a failure — a mirror of a repository that has never been pushed to is trivially up to date, yet it read as an error forever. SyncPolicy.AllowEmptySource (off by default) opts into the distinction. With it set, Replicate reports ErrNoRefsSelected when the source does advertise refs, ErrSourceEmptyUnverified when it advertised none but never confirmed it is empty, ErrSourceEmptyTargetPopulated when it is confirmed empty while the target still holds refs, and a zero-plan success carrying ExecutionSummary.SourceEmpty when source and target are both empty and therefore already agree. Emptiness is established from what the source asserts, never inferred from a response that merely carried no refs. git-sync now requests protocol v2's ls-refs=unborn where the server advertises it, so a repository with no commits answers with an explicit "unborn HEAD" line; only that assertion, under an all-refs scope, qualifies. The distinction is the point: a blank body behind a valid header, a server-side ref-listing or hide-pattern regression, or a narrowed ref-prefix all produce the same silence as an empty repository, and a caller acting on silence would act on every affected repository at once. The unborn line's symref-target is deliberately not surfaced as SourceHEAD, which consumers read as a branch that exists. The divergent case refuses rather than converging. Converging means deleting every ref on the target, and the states that produce that signature — a source restored from backup, a wiped data plane, an out-of-band emptying — are the ones where the target may hold the only surviving copy. The opt-in gate is checked first, so "off" is structurally identical to the behavior that predates this and not merely identical in the cases someone thought to test: a caller that has not opted in cannot receive a sentinel it has never heard of. The sentinels' messages deliberately avoid the historical "no source refs matched" text, so a caller that substring-matches that phrase cannot read one as the other and the order the checks run in is not load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01714HJZAqpgwuwp6fcMWEhG Entire-Checkpoint: 01M0JBEEG33N57NPRZ1MAT4DY6
fe0c3b5 to
d71f306
Compare
The converged path rested on a false reading of the protocol. `unborn HEAD` means only that HEAD's symref target does not exist; it says nothing about whether other refs exist. Verified against git 2.53: a repository holding refs/heads/other with HEAD pointed at a never-created refs/heads/main reports unborn, and hiding that branch with uploadpack.hideRefs reduces its entire advertisement to the unborn line alone — exactly the input the previous commit treated as proof of an empty repository. No client-side fix exists. Ref hiding is designed to be invisible to the client, so a hidden ref and an absent one are the same observation, and no combination of ls-refs arguments distinguishes them. "This repository has no refs" is therefore not a client-observable fact, and git-sync must stop claiming to establish it. So the assertion becomes an input. SyncPolicy.SourceAssertedEmpty carries the caller's authoritative answer, from a repository-state query that sees past hiding, and git-sync's role is reduced to refusing to act on it unless everything git CAN observe agrees: nothing advertised, HEAD reported unborn, and no advertised ref name dropped as invalid. Every one of those can only refuse — none can promote an absent assertion into a success — so a caller that supplies nothing gets ErrSourceEmptyUnverified however the wire reads. That closes a second instance of the same hole the review did not mention: ref-name validation dropping every advertised name (the new PartitionRefNames path) also leaves the ref set empty while unborn still fires, so RefService.SkippedRefNames is now surfaced and a non-empty one refuses. SourceUnborn is renamed HeadUnborn, because the old name asserted the conclusion rather than the observation, and its doc now says what the line does and does not prove. Also fixes the dry-run flag being dropped from the zero-plan success, so a replicate-mode plan of two empty repositories no longer reports execution.dryRun=false, with a regression test. Tests cover the review's cases: unborn alongside another branch decodes as both facts and never lets one imply the other; an asserted-empty source whose HEAD is born, or that cannot report unborn at all, or whose names were dropped as invalid, all fail closed to unverified against both an empty and a populated target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01714HJZAqpgwuwp6fcMWEhG Entire-Checkpoint: 01M0JDDWHVPD92X8NY6EH6YNKR
The previous commit fixed this asymmetry on the source leg and left it standing
on the target: len(target.refMap) == 0 was still read as proof that the target
holds no refs. It is the same mistake. receive.hideRefs omits matching refs
from receive-pack's advertisement, so a populated target advertises nothing but
the bare capabilities^{} sentinel — verified against git 2.53, where a repo
holding refs/heads/other with receive.hideRefs=refs/heads/other advertises
exactly that.
The target case is the sharper of the two, because receive.hideRefs and
uploadpack.hideRefs are separate settings: the same probe confirms upload-pack
still serves refs/heads/other to fetchers while receive-pack conceals it. A
target wrongly judged empty is therefore one whose READERS see refs the source
does not have — live divergence, reported as convergence, which is the one
direction a watermark claim must never fail in.
So TargetAssertedEmpty joins SourceAssertedEmpty, corroborated the same way and
failing closed to a distinct ErrTargetEmptyUnverified. A VISIBLE target ref
still reports ErrSourceEmptyTargetPopulated rather than an unknown: hiding can
conceal refs but never invent them, so anything advertised is real and that is
divergence, not uncertainty.
Target ref names dropped by validation are now retained rather than only
warned about, closing the same secondary hole the source side already covers:
they leave refMap empty while the target plainly holds refs.
Exported documentation is corrected where it still described the superseded
contract. SyncPolicy said AllowEmptySource relied on the source confirming
emptiness through ls-refs=unborn, which stopped being true when the assertion
became an input; ErrSourceEmptyUnverified said it meant a missing unborn
assertion, when it covers a missing caller assertion and dropped ref names as
well. Both now describe what the implementation actually requires, so an
embedder cannot omit an assertion or misread the error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01714HJZAqpgwuwp6fcMWEhG
Entire-Checkpoint: 01M0JE3619BM1KPE2YXS7Y5XVP
…guard the class unstable.Client accepted gitsync.SyncPolicy and dropped AllowEmptySource, SourceAssertedEmpty and TargetAssertedEmpty on the floor, so Plan/Sync/Replicate there could not use the feature at all — it was accepted by the API and then ignored. The interesting part is why no test failed. unstable already had a test asserting that "advanced options" propagate, and it enumerates the fields it checks by hand, so it covered exactly what someone had remembered to add to it. A newly declared policy field is therefore invisible to it by construction. That is the same shape as the two protocol findings on this branch: the check existed, and the check's own blind spot was the bug. So both config builders now get a reflection guard: for every bool on SyncPolicy, set it alone and require the same-named bool on syncer.Config to be set. A new policy bool is covered the moment it is declared, and the test fails until it is threaded — verified by removing one assignment and watching it go red, rather than trusting that it would. A field whose config counterpart is deliberately named differently, or deliberately absent, is meant to be listed in the skip map with a reason instead of quietly renamed to pass. Also corrects the ErrNoRefsSelected doc, which described the empty-source errors below it as meaning the source has "no refs AT ALL". That contradicts the fail-closed contract those errors exist to express: they cover a source that ADVERTISED no refs, which is deliberately the weaker statement, because whether the repository really holds none is not something a client can determine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01714HJZAqpgwuwp6fcMWEhG Entire-Checkpoint: 01M0JF4F1CK0PDKS2AG0JF5DYN
|
bugbot run |
unstable's buildSyncConfig, buildBootstrapConfig and buildFetchConfig each
forwarded Scope.ExcludeRefPrefixes but dropped Scope.ExcludeRefs; the stable
client threads both. Under Policy{Prune:true} planner.IsRefExcluded therefore
never matched, so a ref the caller had explicitly reserved — a directory-anchor
name like refs/heads/entire — became a prune candidate and was deleted from the
target, and overwritten from the source when present.
The reflection guard added to catch exactly this class walked only bool fields
on SyncPolicy, so it could not see a dropped RefScope slice. It now covers both
structs, and the two near-identical copies of it (which had already begun to
drift) are replaced by one implementation in internal/syncertest:
- exported-field filter: an unexported bool on SyncPolicy previously made
both copies panic inside SetBool instead of naming the field;
- kind check on the config side: a same-named field of a different type
panicked at Bool() rather than reaching the Fatalf that tells the author to
thread it;
- the always-empty `skip` map is now a parameter, so its lookup branch is
reachable rather than dead.
Bootstrap and Fetch get the same guard as Sync, since they take the same
RefScope and dropped the same field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL
Entire-Checkpoint: 01M0JPXF237GDW2AMXECSEYYWV
Follow-up review of the empty-source work found the policy inert or wrong on several of its own headline paths. Reachability. The emptiness decision hung off len(desiredRefs) == 0, after planning. For any request pinning refs by mapping, planner.BuildDesiredRefs errors on the absent mapped source ref first, so a mapping-scoped mirror of a genuinely empty repository got "source ref X not found" — matching none of the new sentinels — on exactly the state the policy exists to make succeed. An empty advertisement is now resolved before planning, where under AllRefs it is already a complete observation. Gated on the opt-in, so a caller that never asked for this still gets the planner's error verbatim. Scope. The target-populated check counted every advertised target ref, ignoring exclusions and zero hashes, unlike every other consumer of the target ref map (replicateCanBootstrap, addPruneCandidates). A mirror that trims refs/pull/* whose target held only refs/pull/1/head was reported as permanently diverged over a ref the run would neither push nor prune — and refs/pull/* is the namespace this package's own docs cite as the benign case. Contract. ExecutionSummary.SourceEmpty is renamed Converged: it requires both sides verified, so it was false in every other outcome including the diverged one where the source WAS verified empty. It also loses `omitempty` — for the field whose whole purpose is separating a converged run from a no-op, "false" and "this binary has no such field" must not be the same JSON — and is now rendered by Result.Lines(), so the text output cmd/git-sync actually prints is no longer byte-identical to an ordinary zero-work sync. The converged result carries the Relay fields every other successful replicate return sets. Validation. AllowEmptySource silently required AllRefs, was silently discarded outside replicate mode, and could never succeed over protocol v1, whose ls-refs has no unborn signal. All three are now rejected at the request edge instead of threaded in and dropped. The v1 case validation cannot see — an "auto" SSH source that falls back mid-run — reports that the protocol cannot carry the signal, rather than "did not report an unborn HEAD", which reads as the server withholding refs and points an operator at a hideRefs misconfiguration or a compromised source. A v2 source not advertising ls-refs=unborn was misreported the same way and is now distinguished too. Corroboration. RefService.SkippedRefNames was populated at one of four construction sites, leaving the invalid-name cross-check vacuous on v1; it fails closed today only because the !HeadUnborn check happens to run first. It is now a count set on every path through newV1RefService. A count also stops the slice being pinned to a struct that outlives the pack transfer — megabytes retained on a source advertising many invalid names, to answer a boolean. Coverage. Every test hand-built a syncSession, so the chain the design rests on (ls-refs "unborn" -> decodeV2LSRefs -> RefService.HeadUnborn -> Config -> converged Result) had none: deleting the request argument left the whole suite green. The in-package fake v2 server advertised ls-refs=unborn but never emitted an unborn line; it does now, and Run is exercised end to end. Reverting any fix here turns a test red. The argument staying ungated is deliberate — it costs no round trip and lets Probe report an unborn HEAD without a convergence policy — and is pinned by tests, along with the advertisement gate that does matter. Also: guard s.target, which is legitimately nil on Fetch and target-less Probe sessions; correct ErrNoRefsSelected's doc, which named two causes unreachable by construction; label the divergence count; document the unborn argument in docs/protocol.md and correct the CHANGELOG's "nothing changes for existing callers", which the ungated request falsifies for every v2 caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL Entire-Checkpoint: 01M0JPYDW1P7C7KB0TS9JM24P7
|
bugbot run |
Bugbot caught that the divergence check's idea of scope was exclusions-only, while the planner's is wider: with Mappings set, addPruneCandidates declines to manage unmapped branches and other namespaces too. A mapping-pinned mirror whose target held any unmapped branch was therefore reported as permanently diverged over a ref the run would neither push nor prune — the same false divergence the exclusion filter fixed, defeating the very case the pre-planning path exists to serve. The cause is that "does this request manage this target ref" had been written out three times, so a fourth copy would repeat the mistake. It is now planner.PruneTarget, which addPruneCandidates and the divergence check share. replicateCanBootstrap deliberately keeps its own broader branch rule (under AllRefs a stale branch matters even with a Branches filter set), which is identical to this one wherever AllowEmptySource applies, since that policy requires AllRefs. PruneTarget normalizes its config rather than assuming a normalized one: syncer.planConfig does not normalize, and reading the 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 in fact prune it, so the run converges over a populated target instead of refusing. Both behaviors are pinned by tests that fail if the fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL Entire-Checkpoint: 01M0K3Y9622187AV0J1TCK78X1
|
bugbot run |
The previous commit read planner.PruneTarget as "refs this request manages". It is not: it answers whether prune could select an ALREADY-UNMANAGED ref, and addPruneCandidates only consults it after skipping the managed set. With Mappings set it therefore reports false for every branch — including the mapping targets themselves. So the divergence check stopped seeing the one ref a mapping-pinned request most obviously owns. An empty source whose target still held the mapped ref counted zero refs in scope and converged, or reported ErrTargetEmptyUnverified instead of divergence. Converging there means deleting that ref, which is the outcome this whole path exists to refuse, and it is the same mapping case the two preceding commits were meant to fix. Scope is now planner.TargetScope: a target ref is the request's responsibility if it is a declared mapping target, or if prune could select it. Mapping targets are resolved through validation.ValidateMappings, the same call BuildDesiredRefs uses, so the two cannot disagree about what a mapping names, and short-form mappings match the full ref the target advertises. They are also in scope regardless of exclusions, matching the mapping pass in BuildDesiredRefs, which applies exclusions only to auto-discovery. PruneTarget's doc now says what it does not answer, since reading it as whole-scope is what went wrong. The gap was in the tests as much as the code: the mapping cases covered only refs the request does not own, so nothing exercised a target holding the mapped ref. That case, the excluded-but-mapped case, and short-form resolution are all covered now, and all three fail against the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL Entire-Checkpoint: 01M0K49M97M6VEWX1VPC0ADWX4
|
bugbot run |
BuildDesiredRefs' auto-discovery pass — tags, and other-kind names under
AllRefs — sits outside the mapping/branch branch, so a mapping-scoped AllRefs
request still mirrors refs/notes/* and tags. Prune is the narrower set: it
skips both once Mappings is set. TargetScope.Manages delegated wholly to
PruneTarget and so reported those refs out of scope, letting an empty source
converge against a target holding refs the config actively mirrors.
Manages is now the union of the two halves, which is what its own doc always
claimed ("would push to, or prune"). Exclusions still apply to the
auto-discovery half, matching BuildDesiredRefs, and mapping targets still
bypass them.
PruneTarget is deliberately left alone. Widening it would change what prune
deletes, which is a live behaviour change well outside this branch — the
asymmetry between push and prune scope under mappings is the planner's existing
contract, not a bug this PR should quietly alter.
The previous revision of the mapping test asserted the wrong thing here: it
expected an unmapped namespace to converge, by analogy with unmapped branches.
Branches really are out of scope under mappings (the branch pass is in the
else); other-kind refs are not. Both cases are now covered, along with the
excluded-namespace counterpart that keeps the exclusion behaviour honest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL
Entire-Checkpoint: 01M0K4MP27NST43HX4HC4HP7KC
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6d1eb86. Configure here.
Soph
left a comment
There was a problem hiding this comment.
No blockers. The dangerous direction — Converged: true while the target holds refs the source lacks — is guarded, and I verified it by mutation testing rather than reading: reverting Manages to delegate wholly to PruneTarget, forcing scope.Manages true, disabling the inScope > 0 check, dropping the unborn ls-refs argument, and re-dropping Scope.ExcludeRefs in unstable each turn a test red. Build, vet and the full suite are green.
Five findings below — two medium, three low. None is a correctness bug.
… they live Five findings from review, none a correctness bug. All five stand. Relay capability is now checked before the empty-source intercept rather than after planning. The converged result claims Relay: true on the grounds that "replicate refuses a non-relay target outright" — but the intercept returned above that check, so a target whose receive-pack advertisement carries no capabilities got a success asserting a relay the ordinary path would have refused. Nothing moves either way, so convergence is arguably still the right answer, but the field was fabricated in a change whose subject is honest reporting. s.target.policy is populated in newSession, so the check simply moves up. ProbeResult.SourceHeadUnborn makes a claim in the PR body true rather than restating it. The body defended sending `unborn` on every v2 ls-refs partly because it lets Probe report an unborn HEAD without a convergence policy — which was not implemented: HeadUnborn had exactly one reader, behind AllowEmptySource. Probe is the diagnostic surface and the argument is already on the wire, so an operator asking why a mirror will not converge can now see whether the source reported unborn at all. Diagnostic only; it carries none of the policy's weight, and its doc says so. TargetScope and PruneTarget get planner-local tests. Their semantics were wrong in three consecutive commits and PruneTarget now sits on the live prune path, yet every assertion about them lived in internal/syncer. The table covers mapping target, excluded-but-mapped, short-form mapping names, tags under both AllRefs and IncludeTags, other-kind under AllRefs, unmapped branches with and without mappings, both exclusion forms, prune disabled, and an un-normalized Branches filter — plus the PruneTarget-is-narrower property whose conflation caused two of the three regressions. Manages' doc no longer promises to track cfg.Prune. It deliberately does not: the question is whose ref this is, not what this run would do to it, and "source empty, target holds refs" is divergence whether or not this run would have pruned. Behaviour unchanged; the doc was overpromising. buildProbeConfig joins the reflection guard. It is the one request-edge builder outside it, and while ProbeRequest has no ExcludeRefs today, it is exactly where that bug class could recur unseen. CollectStats reaches syncer.Config as ShowStats, so it is skipped with a reason and covered by its own assertion — which also makes the previously-dead skip branch live. Finally, resolveEmptyDesiredSet's doc no longer implies both entry points are live. The pre-planning intercept means its delegation is currently unreachable; it is kept so the paths cannot drift if that gate is loosened, and now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvpGRBapBppY4xh2x38kDL Entire-Checkpoint: 01M0SHVJW7ZX6S8F925MKM07TM
Replicate failed every run whose planning produced no desired refs, with one message —
no source refs matched— covering unrelated conditions: the source has no refs, the source has refs the requested scope excluded, and the source has refs this reader was never shown. A caller cannot tell them apart, and the first is not always a failure: a mirror of a repository that has never been pushed to is trivially up to date, yet it reads as an error forever.SyncPolicy.AllowEmptySource(off by default) opts into separating them:ExecutionSummary.ConvergedErrSourceEmptyTargetPopulatedErrSourceEmptyUnverifiedErrTargetEmptyUnverifiedErrNoRefsSelectedno source refs matchedThe policy has three requirements, all rejected at the request edge rather than accepted and then ignored:
Modemust beModeReplicate,RefScope.AllRefsmust be set (under a narrower scope the source listing is itself narrowed, so an empty result says nothing about the repository), and the source protocol must not be pinned to v1 — v1's ls-refs has no unborn signal, so the cross-check below could never be satisfied over it.ProtocolAutois fine; it negotiates v2 wherever the server supports it.git-sync does not decide that a repository is empty
It can't, on either leg. Ref hiding is invisible to the client by design, so a hidden ref and an absent one are the same observation — and the two legs hide independently.
Source. An unborn HEAD means only that HEAD's symref target does not exist. Against git 2.53:
Target.
receive.hideRefsomits matching refs from receive-pack's advertisement:Both second responses are populated repositories, byte-identical to what empty ones send. The target case is the sharper of the two:
receive.hideRefsanduploadpack.hideRefsare separate settings, and the same probe confirms upload-pack still servesrefs/heads/otherto fetchers while receive-pack conceals it — so a target wrongly judged empty is one whose readers see refs the source does not have.So both assertions are inputs:
SyncPolicy.SourceAssertedEmptyandTargetAssertedEmpty, supplied from repository-state queries that see past hiding. git-sync's role is to refuse to act on them unless everything it can observe agrees — nothing advertised on either side, HEAD reported unborn on the source (RefService.HeadUnborn, via protocol v2ls-refs=unbornwhere advertised), and no advertised ref name dropped as invalid on either. Those checks can only refuse; none turns an absent assertion into a success, so a caller supplying nothing gets an unverified error however the wire reads.A visible target ref is still divergence rather than an unknown: hiding can conceal refs but never invent them, so anything advertised is real. Divergence refuses rather than converging — converging means deleting every ref on the target, and the states that produce that signature (a source restored from backup, a wiped data plane, an out-of-band emptying) are the ones where the target may hold the only surviving copy.
Compatibility
The opt-in gate is checked before anything else, so a caller that has not opted in cannot receive a sentinel it has never heard of. The sentinels also avoid the historical
no source refs matchedtext, so a consumer that substring-matches that phrase cannot read one as the other, and check order is not load-bearing. Both were defects in earlier revisions of this branch, each caught by a test rather than by reading.Commits after the first review round
SourceUnborn→HeadUnborn; its doc now states what the line does and does not prove.PartitionRefNamesvalidation are retained on both sides and refuse a convergence claim: they leave a ref set empty while the repository plainly holds refs.DryRunwas dropped from the zero-plan success, so a replicate-mode plan of two empty repositories reportedexecution.dryRun=false. Fixed, with a regression test.SyncPolicyandErrSourceEmptyUnverifiedcorrected where they still described the superseded unborn-only contract.Commits after the second review round
A follow-up review found the policy inert or wrong on several of its own headline paths.
It could never fire for a mapping-scoped mirror. The decision hung off
len(desiredRefs) == 0, after planning — butplanner.BuildDesiredRefserrors on a mapped source ref that is absent, which on a genuinely empty source is every mapping. A mapping-pinned mirror gotsource ref X not found, matching none of the sentinels, on exactly the state the policy exists to make succeed. An empty advertisement is now resolved before planning, where underAllRefsit is already a complete observation.Divergence ignored the request's own scope. The target check counted every advertised ref, unlike every other consumer of the target ref map. A mirror trimming
refs/pull/*whose target held onlyrefs/pull/1/headwas reported as permanently diverged over a ref it would neither push nor prune — andrefs/pull/*is the namespace these docs cite as the benign case.SourceEmpty→Converged, and it is now visible. The flag requires both sides verified, sofalsedid not mean "the source has refs" — it was false in the diverged outcome too, where the source was verified empty. It also carriedomitempty, alone among the discriminating bools, so for the field whose whole purpose is separating a converged run from a no-op,falseand "this binary has no such field" were the same JSON. AndResult.Lines()never rendered it, so the text output the CLI actually prints was byte-identical to an ordinary zero-work sync.The corroboration was vacuous on v1.
RefService.SkippedRefNameswas set at one of four construction sites; it failed closed only because the!HeadUnborncheck happens to run first. It is now a count set on every path through one constructor — which also stops the slice being pinned to a struct that outlives the pack transfer.A protocol limit was reported as a hostile server.
did not report an unborn HEADtells an operator their source is withholding refs, pointing at ahideRefsmisconfiguration or a compromised source. Over v1 — including anautoSSH source that falls back mid-run — the real cause is that the protocol has no such signal. The two are now distinguished, as is a v2 source that does not advertisels-refs=unborn.Nothing covered the wiring the design rests on. Every test hand-built a
syncSession, so the chain from the ls-refs argument throughHeadUnbornto a convergedResulthad no coverage: deleting the request argument left the whole suite green. The in-package fake v2 server advertisedls-refs=unbornbut never emitted an unborn line; it does now, andRunis exercised end to end. Reverting any fix in this round turns a test red.Also:
s.targetis guarded (it is legitimately nil onFetchand target-lessProbesessions);ErrNoRefsSelected's doc named two causes unreachable by construction; the divergence count is labelled; anddocs/protocol.mdnow documents the unborn argument.One caller-visible wire change
unbornis appended to every v2 ls-refs request whose server advertises support for it — probe, plan, sync, replicate, bootstrap, fetch — not only for callers who opted into this policy. That is deliberate: it costs no round trip, and a source with commits answers exactly as before. It does mean the earlier claim that nothing changes for existing callers was too strong for the wire, so the CHANGELOG is corrected and both the argument and its advertisement gate are pinned by tests.Review caught that the original justification here — that it lets
Probereport an unborn HEAD — was not actually implemented;HeadUnbornhad a single reader behind the policy. Rather than walk the rationale back,ProbeResult.SourceHeadUnbornnow exists, so the claim is true: an operator asking "why will this mirror not converge" can see whether the source reported unborn at all, which is otherwise unobservable from outside. It is diagnostic only and carries none of the policy's weight.What "in scope" means for divergence
Three Bugbot rounds went into getting this right, so it is worth stating plainly. A target ref counts as divergence only if the request is responsible for it —
planner.TargetScope.Manages, which is the union of push scope and prune scope:BuildDesiredRefs' mapping pass)IncludeTagsorAllRefsrefs/notes/*, …) underAllRefsMappingssetMappingssetelse, and prune skips itExcludeRefPrefixes/ExcludeRefsBoth directions of getting this wrong are real and they fail oppositely: too narrow leaves a mirror permanently diverged over a ref it would never touch, too wide converges over a target still holding refs the source does not have. The second is the one that deletes data, and it is what the last two rounds were.
planner.PruneTargetanswers only the prune half and is deliberately not widened — that would change what prune actually deletes, a live behaviour change outside this branch. The push/prune asymmetry under mappings is the planner's existing contract.Separate commit: a prune bug this branch did not introduce
unstable'sbuildSyncConfig,buildBootstrapConfigandbuildFetchConfigeach forwardedScope.ExcludeRefPrefixesbut droppedScope.ExcludeRefs, which the stable client threads. UnderPrune, a ref the caller had explicitly reserved became a prune candidate and was deleted from the target. It is first in the branch and independent of everything above — happy to split it out if you would rather review it alone.It is also the class the reflection guard was added to catch, and could not: the guard walked only bool fields on
SyncPolicy, neverRefScope. It now covers both, from one shared implementation instead of two copies that had already drifted, and no longer panics on an unexported field or a same-named field of a different type.No behavior change for the CLI or any existing embedder beyond the ls-refs argument noted above. Note that neither assertion has a caller in this PR, so nothing reports a converged empty source yet — the safe direction, and what the opt-in default gives anyway.
🤖 Generated with Claude Code
https://claude.ai/code/session_01714HJZAqpgwuwp6fcMWEhG
Note
Medium Risk
Changes replicate empty-set classification and v2 ls-refs on the wire, plus prune scope via ExcludeRefs. Default empty-source errors stay the same unless callers opt in, but a wrong emptiness claim could still mis-report convergence vs divergence.
Overview
Adds opt-in
SyncPolicy.AllowEmptySourceso Replicate can treat a verified empty source as success instead of collapsing every empty desired set intono source refs matched.Callers must assert emptiness on both sides (
SourceAssertedEmpty/TargetAssertedEmpty); git-sync only corroborates (empty ads, v2 unborn HEAD, no dropped invalid names, no in-scope target refs). Outcomes:Convergedzero-plan success,ErrNoRefsSelected,ErrSourceEmptyUnverified,ErrTargetEmptyUnverified, orErrSourceEmptyTargetPopulated(refuses rather than deleting target refs). Policy is replicate-only, requiresAllRefs, and rejects pinned v1 at the request edge. Off by default.Every v2
ls-refsnow requestsunbornwhen advertised (not policy-gated). Unstable config builders now threadExcludeRefs(was dropped, so prune could delete reserved refs). Reflection guards cover policy/scope field threading.Reviewed by Cursor Bugbot for commit 6d1eb86. Configure here.