Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Added

- **`SyncPolicy.AllowEmptySource` — an empty source can now be an outcome instead of an error, when a source of truth says so.** Replicate previously failed any 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 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. With the policy set, Replicate reports them separately: `ErrNoRefsSelected` when the source does advertise refs, `ErrSourceEmptyUnverified` / `ErrTargetEmptyUnverified` when either side's emptiness could not be established, `ErrSourceEmptyTargetPopulated` when the source is empty while the target still holds refs (a real divergence — refused rather than converged, since converging means deleting them), and a zero-plan success with `ExecutionSummary.Converged` when source and target are both empty and therefore already agree.

**git-sync does not decide that a repository is empty, and will not guess.** It cannot: ref hiding is designed to be invisible to the client, so a hidden ref and an absent one are the same observation. An unborn HEAD does not close the gap either — git emits that line for any dangling HEAD, so a repository holding `refs/heads/other` with HEAD pointed at a never-created `refs/heads/main` reports unborn, and hiding that branch reduces its entire advertisement to the unborn line alone (verified against git 2.53). The assertion is therefore an input: `SyncPolicy.SourceAssertedEmpty`, which the caller supplies from a repository-state query that sees past hiding.

The target needs the same treatment, via `SourceAssertedEmpty`'s counterpart `TargetAssertedEmpty`, and for a sharper reason: `receive.hideRefs` omits matching refs from receive-pack's advertisement, so a populated target can advertise nothing but the bare `capabilities^{}` sentinel — and because `receive.hideRefs` and `uploadpack.hideRefs` are separate settings, a ref hidden from the push side is still served to fetchers, so a target wrongly judged empty is one whose readers see refs the source does not have.

What git-sync contributes is a consistency check on those claims, and it only ever refuses. Before reporting convergence it independently requires an empty advertisement on each side, an unborn HEAD on the source (now requested via protocol v2's `ls-refs=unborn` where the server advertises it, and surfaced as `RefService.HeadUnborn`), no advertised ref name dropped as invalid on either side, and no visible target ref within the request's scope (an excluded namespace the run would neither push nor prune is not divergence). None of those can promote an absent assertion into a success, so a caller cannot get a false converge out of a compliant server, and a caller that supplies no assertion gets `ErrSourceEmptyUnverified` or `ErrTargetEmptyUnverified` no matter what the wire says. The unborn line's `symref-target` is deliberately not surfaced as `SourceHEAD`, which consumers read as a branch that exists.

Off by default: without the opt-in an empty source still fails with the historical message, and the new sentinels deliberately do not carry that text so a caller still matching on it cannot mistake a divergence for the old benign no-op. The policy is replicate-only and requires `RefScope.AllRefs` — under a narrower scope the source ref listing is itself narrowed, so an empty result says nothing about the repository as a whole — and both requirements are now rejected at the request edge rather than accepted and silently discarded. Convergence also requires protocol v2 on the source leg, since the unborn cross-check has no v1 equivalent: `ProtocolV1` is rejected at the request edge, and an `auto` source that falls back to v1 mid-run reports that the protocol cannot carry the signal rather than implying the server withheld refs.

One thing does change for every v2 caller, opt-in or not: `unborn` is appended to each `ls-refs` request whose server advertises support for it (see `docs/protocol.md`). It adds no round trip and a source with commits answers exactly as before, but the request bytes differ, so a test asserting on the exact ls-refs body will need updating. Only the *reader* of the resulting flag is gated on the policy.

- A `Vulnerability Scan` workflow running `govulncheck ./...` on pull requests, pushes to main, and a weekly schedule. The existing lint suite cannot see this class of issue, and the weekly run matters because advisories are published against versions already in go.mod — without it, a newly disclosed vulnerability goes unreported until someone happens to open a PR.

## [0.8.0] - 2026-07-09
Expand Down
10 changes: 10 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func (c *Client) buildSyncConfig(ctx context.Context, req SyncRequest, dryRun bo
ForceBlind: req.Policy.ForceBlind,
Prune: req.Policy.Prune,
BestEffort: req.Policy.BestEffort,
AllowEmptySource: req.Policy.AllowEmptySource,
SourceAssertedEmpty: req.Policy.SourceAssertedEmpty,
TargetAssertedEmpty: req.Policy.TargetAssertedEmpty,
ProtocolMode: string(req.Policy.Protocol),
MaterializedMaxObjects: syncer.DefaultMaterializedMaxObjects,
}, nil
Expand Down Expand Up @@ -176,6 +179,13 @@ func validateSyncFields(source, target Endpoint, scope RefScope, policy SyncPoli
if _, err := validation.ValidateMappings(validationMappings(scope.Mappings), scope.AllRefs); err != nil {
return fmt.Errorf("validate mappings: %w", err)
}
// The half of the AllowEmptySource contract that needs the scope as well as
// the policy, so it cannot live on SyncPolicy.Validate. Silently accepting
// it left the caller with the historical "no source refs matched" and no
// hint that their policy had been discarded.
if policy.AllowEmptySource && !scope.AllRefs {
Comment thread
nodo marked this conversation as resolved.
return errors.New("AllowEmptySource requires Scope.AllRefs; a narrowed scope cannot establish that a repository is empty")
}
return nil
}

Expand Down
108 changes: 108 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,111 @@ func (s *smartHTTPRepoServer) writeReceivePackReport(w http.ResponseWriter, repo
type nopWriteCloser struct{ io.Writer }

func (nopWriteCloser) Close() error { return nil }

// The stable Client's config builder gets the same reflection guard as
// unstable's, for the same reason: an enumerated list of fields only covers
// what someone remembered to add, so a newly declared policy bool can be
// accepted by the API and silently ignored with every test still green. See
// unstable's TestBuildSyncConfigThreadsEveryPolicyBool — that is where this
// class of omission was actually found. Both call one shared implementation so
// the two copies cannot drift.
func TestBuildSyncConfigThreadsEveryPolicyBool(t *testing.T) {
syncertest.AssertFieldsThreaded(t, nil, func(t *testing.T, policy SyncPolicy) any {
cfg, err := New(Options{}).buildSyncConfig(context.Background(), SyncRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
Target: Endpoint{URL: "https://target.example/repo.git"},
Policy: policy,
}, false)
if err != nil {
t.Fatalf("buildSyncConfig: %v", err)
}
return cfg
})
}

func TestBuildSyncConfigThreadsEveryScopeField(t *testing.T) {
syncertest.AssertFieldsThreaded(t, nil, func(t *testing.T, scope RefScope) any {
cfg, err := New(Options{}).buildSyncConfig(context.Background(), SyncRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
Target: Endpoint{URL: "https://target.example/repo.git"},
Scope: scope,
}, false)
if err != nil {
t.Fatalf("buildSyncConfig: %v", err)
}
return cfg
})
}

// AllowEmptySource has two requirements that no path would otherwise report:
// the policy is replicate-only, and it needs an unscoped request. Both were
// accepted at the edge, threaded into the syncer, and then discarded — the
// caller got the historical "no source refs matched" with no hint that their
// safety policy had been ignored.
func TestValidateRejectsUnusableAllowEmptySource(t *testing.T) {
base := SyncRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
Target: Endpoint{URL: "https://target.example/repo.git"},
}

replicateUnscoped := base
replicateUnscoped.Policy = SyncPolicy{Mode: ModeReplicate, AllowEmptySource: true}
if err := replicateUnscoped.Validate(); err == nil {
t.Error("expected a scoped AllowEmptySource replicate to be rejected")
}

syncMode := base
syncMode.Scope = RefScope{AllRefs: true}
syncMode.Policy = SyncPolicy{Mode: ModeSync, AllowEmptySource: true}
if err := syncMode.Validate(); err == nil {
t.Error("expected AllowEmptySource outside replicate to be rejected")
}

// Mode unset defaults to sync, so it must be rejected the same way rather
// than slipping through on the zero value.
modeUnset := base
modeUnset.Scope = RefScope{AllRefs: true}
modeUnset.Policy = SyncPolicy{AllowEmptySource: true}
if err := modeUnset.Validate(); err == nil {
t.Error("expected AllowEmptySource with an unset mode to be rejected")
}

ok := base
ok.Scope = RefScope{AllRefs: true}
ok.Policy = SyncPolicy{Mode: ModeReplicate, AllowEmptySource: true, SourceAssertedEmpty: true, TargetAssertedEmpty: true}
if err := ok.Validate(); err != nil {
t.Errorf("an unscoped replicate with the policy set must validate, got %v", err)
}
}

// buildProbeConfig is the one request-edge builder the guard above does not
// cover, because ProbeRequest carries flat fields rather than a RefScope. It
// drops nothing today — ProbeRequest has no ExcludeRefs — but it is exactly
// where the bug class the guard exists for could recur unseen, so it gets the
// same treatment.
func TestBuildProbeConfigThreadsEveryField(t *testing.T) {
syncertest.AssertFieldsThreaded(t, map[string]string{
"CollectStats": "deliberately renamed: reaches syncer.Config as ShowStats",
}, func(t *testing.T, req ProbeRequest) any {
req.Source = Endpoint{URL: "https://source.example/repo.git"}
cfg, err := New(Options{}).buildProbeConfig(context.Background(), req)
if err != nil {
t.Fatalf("buildProbeConfig: %v", err)
}
return cfg
})
}

// The renamed field still has to arrive, it just cannot be checked by name.
func TestBuildProbeConfigThreadsCollectStats(t *testing.T) {
cfg, err := New(Options{}).buildProbeConfig(context.Background(), ProbeRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
CollectStats: true,
})
if err != nil {
t.Fatalf("buildProbeConfig: %v", err)
}
if !cfg.ShowStats {
t.Error("ProbeRequest.CollectStats = true was dropped by buildProbeConfig")
}
}
15 changes: 14 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ agent=git-sync/...
0001
peel
symrefs
unborn
ref-prefix HEAD
ref-prefix refs/heads/
ref-prefix refs/tags/
Expand All @@ -159,13 +160,25 @@ ref-prefix refs/tags/

The `peel` and `symrefs` arguments are ls-refs request features that ask the server to include peeled object IDs (for tags) and symref-target attributes. `ref-prefix HEAD` is unconditionally included so HEAD shows up in the response even when the caller only asked for `refs/heads/` or `refs/tags/`.

`unborn` asks the server to report a HEAD whose target does not exist, instead of answering with no ref lines at all. It is appended only when the server advertised `ls-refs=unborn` (protocol v2 forbids sending an unadvertised argument, and a strict server may fail the command), and it is **not** gated on any caller policy: every v2 source listing sends it — probe, plan, sync, replicate, bootstrap, fetch and convert-sha256 alike. It costs no extra round trip, and a source with commits answers exactly as it did before. Only the reader of the resulting flag is policy-gated (see `SyncPolicy.AllowEmptySource`).

The server's HEAD line then looks like:

```
<hash> HEAD symref-target:refs/heads/main
```

`decodeV2LSRefs` parses the line, extracts the `symref-target:` attribute, and returns it as `headTarget` alongside the ref slice. HEAD itself is filtered out of the returned refs because it is a symbolic ref, not a real one — matching v1 behavior where symrefs are filtered out by the downstream `RefHashMap`.
For an unborn HEAD the server instead emits:

```
unborn HEAD symref-target:refs/heads/main
```

`decodeV2LSRefs` parses the born line, extracts the `symref-target:` attribute, and returns it as `headTarget` alongside the ref slice. HEAD itself is filtered out of the returned refs because it is a symbolic ref, not a real one — matching v1 behavior where symrefs are filtered out by the downstream `RefHashMap`.

The unborn line is recorded as `RefService.HeadUnborn` and deliberately does **not** populate `headTarget`: consumers read a non-empty `HeadTarget` as a branch that exists on the source, and an unborn target does not. `HeadUnborn` is only ever a *disqualifier* for a caller's emptiness assertion — a repository holding `refs/heads/other` with HEAD pointed at a never-created `refs/heads/main` reports unborn too, so its presence proves nothing on its own. There is no v1 equivalent, so a v1 source (or an SSH source that falls back to v1) always leaves it false.

Because convergence cannot be corroborated without it, `SyncPolicy.AllowEmptySource` rejects `ProtocolV1` at the request edge. `ProtocolAuto` is accepted — it negotiates v2 wherever the server supports it — but an SSH source whose v2 probe fails and falls back to v1 can only be caught mid-run, where it reports that the *protocol* cannot carry the signal rather than implying the server withheld refs. A v2 source that does not advertise `ls-refs=unborn` is reported the same way.

### Consumers

Expand Down
67 changes: 66 additions & 1 deletion errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package gitsync

import "entire.io/entire/git-sync/internal/gitproto"
import (
"entire.io/entire/git-sync/internal/gitproto"
"entire.io/entire/git-sync/internal/syncer"
)

// ErrTargetRefMoved is returned (wrapped) by Sync and Replicate when a push was
// rejected because the target ref changed concurrently between this run's plan
Expand All @@ -24,3 +27,65 @@ var ErrTargetRefMoved = gitproto.ErrTargetRefMoved
// and Reason is the raw server reason text. Rejections that are concurrent
// target-ref moves also satisfy errors.Is(err, ErrTargetRefMoved).
type RefRejectedError = gitproto.RefRejectedError

// ErrNoRefsSelected is returned (wrapped) by Replicate under
// SyncPolicy.AllowEmptySource when the source advertises refs but
// RefScope.ExcludeRefPrefixes / ExcludeRefs subtracted all of them. The source
// is healthy; the request asked for refs it does not have. Benign for some
// sources by design: a GitHub repository whose only refs are under refs/pull/*
// selects nothing once that namespace is excluded. Test for it with errors.Is.
//
// Exclusions are the only scoping mechanism that reaches it, and the opt-in is
// required — like every sentinel in this family, a caller that has not asked
// for the distinction keeps receiving the historical error. Branch selection
// cannot produce it, because AllowEmptySource requires RefScope.AllRefs and
// that clears any branch filter; nor can a ref mapping, because a mapping whose
// source ref is absent fails earlier, while the source is being planned.
//
// It is deliberately distinct from the empty-source errors below, which cover
// a source that ADVERTISED no refs — a weaker statement on purpose, since
// whether such a repository really holds none is not something a client can
// determine. Before these existed both cases shared one message and callers
// could not tell "nothing was advertised" from "nothing matched".
var ErrNoRefsSelected = syncer.ErrNoRefsSelected

// ErrSourceEmptyUnverified is returned (wrapped) by Replicate under
// SyncPolicy.AllowEmptySource when the source advertised no refs but its
// emptiness could not be established. It covers every way the evidence can
// fall short, not one of them:
//
// - SyncPolicy.SourceAssertedEmpty was not supplied, so there is no
// authoritative claim to act on;
// - the source did not report an unborn HEAD, meaning HEAD's target exists
// and a ref is therefore being withheld;
// - ref-name validation dropped every advertised name, so a repository full
// of refs git would reject arrives looking empty;
// - a blank body behind a valid header, a server-side ref-listing or
// hide-pattern regression, or a narrowed ref-prefix.
//
// Treat it as "unknown", never as "converged". Test for it with errors.Is.
var ErrSourceEmptyUnverified = syncer.ErrSourceEmptyUnverified

// ErrTargetEmptyUnverified is returned (wrapped) by Replicate under
// SyncPolicy.AllowEmptySource when the source was verified empty but the
// TARGET's emptiness could not be established — no SyncPolicy.TargetAssertedEmpty,
// or a target ref name dropped as invalid. Distinct from
// ErrSourceEmptyTargetPopulated, which is a target KNOWN to hold refs.
//
// An empty receive-pack advertisement proves no more than an empty ls-refs
// one: receive.hideRefs omits matching refs from it. And because
// receive.hideRefs and uploadpack.hideRefs are separate settings, a ref hidden
// from the push side is still served to fetchers — so a target wrongly judged
// empty is one whose readers see refs the source does not have. Test for it
// with errors.Is.
var ErrTargetEmptyUnverified = syncer.ErrTargetEmptyUnverified

// ErrSourceEmptyTargetPopulated is returned (wrapped) by Replicate under
// SyncPolicy.AllowEmptySource when the source is confirmed empty while the
// target still holds refs — a real divergence, since nothing the target serves
// exists on the source. Replicate refuses instead of converging: converging
// means deleting every ref on the target, and the states that produce this
// signature (a source restored from backup, a wipe, an out-of-band emptying)
// are the ones where the target may hold the only surviving copy. Test for it
// with errors.Is, and surface it as divergence rather than as "nothing to do".
var ErrSourceEmptyTargetPopulated = syncer.ErrSourceEmptyTargetPopulated
19 changes: 18 additions & 1 deletion internal/gitproto/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,27 @@ func (c *V2Capabilities) Value(name string) string {
// FetchSupports checks whether a specific feature is listed in the
// "fetch" capability value (space-separated feature list).
func (c *V2Capabilities) FetchSupports(feature string) bool {
return c.commandSupports("fetch", feature)
}

// LSRefsSupports checks whether a specific feature is listed in the "ls-refs"
// capability value (space-separated feature list). The only feature defined
// today is "unborn": a server advertising it will report an unborn HEAD as an
// explicit "unborn HEAD symref-target:<ref>" line, which is the difference
// between a repository asserting it has no commits and a response that merely
// carries no ref lines. Protocol v2 forbids sending a command argument the
// server did not advertise, so callers MUST gate the request on this.
func (c *V2Capabilities) LSRefsSupports(feature string) bool {
return c.commandSupports("ls-refs", feature)
}

// commandSupports reports whether feature appears in command's advertised
// space-separated feature list.
func (c *V2Capabilities) commandSupports(command, feature string) bool {
if c == nil {
return false
}
for _, f := range strings.Fields(c.Value("fetch")) {
for _, f := range strings.Fields(c.Value(command)) {
if f == feature {
return true
}
Expand Down
Loading
Loading