Skip to content

Separate an empty source from an unmatched scope, and let a verified-empty source converge - #114

Merged
nodo merged 10 commits into
mainfrom
nodo/cor-1300-empty-source-converged
Aug 24, 2026
Merged

Separate an empty source from an unmatched scope, and let a verified-empty source converge#114
nodo merged 10 commits into
mainfrom
nodo/cor-1300-empty-source-converged

Conversation

@nodo

@nodo nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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:

condition outcome
both sides asserted empty + corroborated zero-plan success, ExecutionSummary.Converged
source empty, target has visible refs in scope ErrSourceEmptyTargetPopulated
source's emptiness not established ErrSourceEmptyUnverified
target's emptiness not established ErrTargetEmptyUnverified
source has refs, exclusions removed all of them ErrNoRefsSelected
not opted in unchanged no source refs matched

The policy has three requirements, all rejected at the request edge rather than accepted and then ignored: Mode must be ModeReplicate, RefScope.AllRefs must 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. ProtocolAuto is 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:

# repo holds refs/heads/other; HEAD -> refs/heads/main, never created
unborn HEAD symref-target:refs/heads/main
<hash> refs/heads/other

# same repo, after: git config uploadpack.hideRefs refs/heads/other
unborn HEAD symref-target:refs/heads/main

Target. receive.hideRefs omits matching refs from receive-pack's advertisement:

# repo holds refs/heads/other
<hash> refs/heads/other report-status ... agent=git/2.53.0

# same repo, after: git config receive.hideRefs refs/heads/other
0000000000000000000000000000000000000000 capabilities^{} report-status ... agent=git/2.53.0

Both second responses are populated repositories, byte-identical to what empty ones send. The target case is the sharper of the two: receive.hideRefs and uploadpack.hideRefs are separate settings, and the same probe confirms upload-pack still serves refs/heads/other to 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.SourceAssertedEmpty and TargetAssertedEmpty, 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 v2 ls-refs=unborn where 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 matched text, 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

  • SourceUnbornHeadUnborn; its doc now states what the line does and does not prove.
  • Emptiness moved from an inference to caller-supplied assertions on both legs, cross-checked as above.
  • Ref names dropped by the new PartitionRefNames validation are retained on both sides and refuse a convergence claim: they leave a ref set empty while the repository plainly holds refs.
  • DryRun was dropped from the zero-plan success, so a replicate-mode plan of two empty repositories reported execution.dryRun=false. Fixed, with a regression test.
  • Exported docs on SyncPolicy and ErrSourceEmptyUnverified corrected 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 — but planner.BuildDesiredRefs errors on a mapped source ref that is absent, which on a genuinely empty source is every mapping. A mapping-pinned mirror got source 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 under AllRefs it 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 only refs/pull/1/head was reported as permanently diverged over a ref it would neither push nor prune — and refs/pull/* is the namespace these docs cite as the benign case.

SourceEmptyConverged, and it is now visible. The flag requires both sides verified, so false did not mean "the source has refs" — it was false in the diverged outcome too, where the source was verified empty. It also carried omitempty, alone among the discriminating bools, so for the field whose whole purpose is separating a converged run from a no-op, false and "this binary has no such field" were the same JSON. And Result.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.SkippedRefNames was set at one of four construction sites; it failed closed only because the !HeadUnborn check 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 HEAD tells an operator their source is withholding refs, pointing at a hideRefs misconfiguration or a compromised source. Over v1 — including an auto SSH 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 advertise ls-refs=unborn.

Nothing covered the wiring the design rests on. Every test hand-built a syncSession, so the chain from the ls-refs argument through HeadUnborn to a converged Result had no coverage: 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 in this round turns a test red.

Also: s.target is guarded (it is legitimately nil on Fetch and target-less Probe sessions); ErrNoRefsSelected's doc named two causes unreachable by construction; the divergence count is labelled; and docs/protocol.md now documents the unborn argument.

One caller-visible wire change

unborn is 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 Probe report an unborn HEAD — was not actually implemented; HeadUnborn had a single reader behind the policy. Rather than walk the rationale back, ProbeResult.SourceHeadUnborn now 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:

target ref in scope?
a declared mapping target yes, and exclusions do not remove it (matching BuildDesiredRefs' mapping pass)
a tag, with IncludeTags or AllRefs yes
other-kind (refs/notes/*, …) under AllRefs yes — auto-discovery pushes these even with Mappings set
an unmapped branch, with Mappings set no — the branch pass is in the else, and prune skips it
anything matching ExcludeRefPrefixes / ExcludeRefs no
a zero hash no — a deletion sentinel, not a ref

Both 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.PruneTarget answers 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's buildSyncConfig, buildBootstrapConfig and buildFetchConfig each forwarded Scope.ExcludeRefPrefixes but dropped Scope.ExcludeRefs, which the stable client threads. Under Prune, 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, never RefScope. 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.AllowEmptySource so Replicate can treat a verified empty source as success instead of collapsing every empty desired set into no 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: Converged zero-plan success, ErrNoRefsSelected, ErrSourceEmptyUnverified, ErrTargetEmptyUnverified, or ErrSourceEmptyTargetPopulated (refuses rather than deleting target refs). Policy is replicate-only, requires AllRefs, and rejects pinned v1 at the request edge. Off by default.

Every v2 ls-refs now requests unborn when advertised (not policy-gated). Unstable config builders now thread ExcludeRefs (was dropped, so prune could delete reserved refs). Reflection guards cover policy/scope field threading.

Reviewed by Cursor Bugbot for commit 6d1eb86. Configure here.

…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
@nodo
nodo force-pushed the nodo/cor-1300-empty-source-converged branch from fe0c3b5 to d71f306 Compare August 21, 2026 14:36
nodo and others added 3 commits August 21, 2026 17:01
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
@nodo

nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/gitproto/refs.go
nodo and others added 2 commits August 21, 2026 19:46
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
@nodo

nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/syncer/empty_source.go
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
@nodo

nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/syncer/empty_source.go Outdated
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
@nodo

nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/planner/planner.go
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
@nodo

nodo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 Soph left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/syncer/empty_source.go
Comment thread internal/syncer/syncer.go
Comment thread internal/syncer/empty_source.go
Comment thread internal/planner/planner.go Outdated
Comment thread client.go
… 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
@nodo
nodo merged commit 3ee9983 into main Aug 24, 2026
4 checks passed
@nodo
nodo deleted the nodo/cor-1300-empty-source-converged branch August 24, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants