Merge pull request #71 from entireio/errors/target-ref-moved · Entire
Merge pull request #71 from entireio/errors/target-ref-moved
92066ee→main·
nodo·1mo ago·7 files·+278 added/-2 removed
errors: expose ErrTargetRefMoved / RefRejectedError on the public API
Changes
7
MCHANGELOG.md+6
Aerrors.go+26
Aerrors_test.go+27
internal
gitproto
- Mpush.go+110/-1
- Mpush_test.go+96
syncer
- Msyncer.go+6/-1
- Msyncer_test.go+7
4 unmodified lines
5
6
7
8
9
10
11
12
13
14
15
16
4 unmodified lines
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- Typed push-rejection errors on the public API.
Sync/Replicatenow report a*RefRejectedError(carrying the rejectedRefand the raw serverReason) for per-ref receive-packngstatuses, reachable witherrors.As. Rejections that are unambiguous concurrent target-ref moves — entire-server's compare-and-swap rejection (remote ref has changed) and git's--force-with-leaselease miss (stale info) — additionally satisfyerrors.Is(err, ErrTargetRefMoved). This lets embedders distinguish a benign racing concurrent push (retryable) from a genuine push failure without substring-matching the free-form error message. Ambiguous markers (non-fast-forward/fetch first) are deliberately excluded from the move classification so a real "needs--force" rejection is not masked. TheForceWithLeaselease-failure escalation (raised even underBestEffort) also satisfieserrors.Is(err, ErrTargetRefMoved), though it is not itself a*RefRejectedError— prefererrors.Isovererrors.Aswhen you only need the cause. The error message and the underlying*packp.CommandStatusErrare preserved unchanged, so existing checks keep working.
[0.6.0] - 2026-06-03
Added
MCHANGELOG.md+6
package gitsync
import "entire.io/entire/git-sync/internal/gitproto"
// ErrTargetRefMoved is returned (wrapped) by Sync and Replicate when a push was
// rejected because the target ref changed concurrently between this run's plan
// and its push — a benign, retryable compare-and-swap / lease miss, not a real
// failure. Test for it with errors.Is(err, gitsync.ErrTargetRefMoved).
//
// On the default push path the concrete error in the chain is a
// *RefRejectedError (reachable with errors.As). One case does NOT carry that
// concrete type: a BestEffort run with ForceWithLease escalates a lease miss
// through a plain wrapped error that still satisfies errors.Is(ErrTargetRefMoved)
// but is not a *RefRejectedError. So prefer errors.Is when you only need the
// cause, and treat a successful errors.As(*RefRejectedError) as best-effort.
//
// This is the supported way to distinguish a racing concurrent push from a
// genuine push failure; prefer it over inspecting the error message text, which
// is free-form and server-specific.
var ErrTargetRefMoved = gitproto.ErrTargetRefMoved
// RefRejectedError is a single per-ref "ng" status returned by the target's
// receive-pack report-status, reachable with errors.As. Ref is the rejected ref
// 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
package gitsync
import (
"errors"
"fmt"
"testing"
"entire.io/entire/git-sync/internal/gitproto"
)
// Compile-time assertion that the exported alias is exactly the internal type,
// so a *RefRejectedError that git-sync constructs internally is reachable via
// errors.As(&gitsync.RefRejectedError{}) by external callers. This does not
// compile if the alias drifts from the internal type.
var _ RefRejectedError = gitproto.RefRejectedError{}
func TestErrTargetRefMovedAliasesInternalSentinel(t *testing.T) {
// Must be the same error value, or errors.Is across the package boundary
// (gitsync.ErrTargetRefMoved vs the internally-wrapped sentinel) would fail.
if !errors.Is(ErrTargetRefMoved, gitproto.ErrTargetRefMoved) {
t.Fatal("gitsync.ErrTargetRefMoved must alias the internal sentinel")
}
wrapped := fmt.Errorf("sync: %w", fmt.Errorf("report-status: %w", ErrTargetRefMoved))
if !errors.Is(wrapped, ErrTargetRefMoved) {
t.Fatal("errors.Is must see ErrTargetRefMoved through wrapping")
}
}
// ErrTargetRefMoved is reported (wrapped) when a push to the target was rejected
// because the target ref changed concurrently between this run's plan and its
// push — a benign, retryable compare-and-swap / lease miss rather than a real
// failure. Test for it with errors.Is(err, ErrTargetRefMoved); the concrete
// error in the chain is a *RefRejectedError. Re-exported publicly as
// gitsync.ErrTargetRefMoved.
var ErrTargetRefMoved = errors.New("target ref moved concurrently")
// RefRejectedError is a single per-ref "ng" status returned by the target's
// receive-pack report-status. Ref is the rejected ref; Reason is the raw,
// server-defined reason text — the git wire protocol carries no structured error
// code, so Reason is free-form and server-specific. Reach it with errors.As.
// Rejections git-sync can prove are concurrent target-ref moves additionally
// satisfy errors.Is(err, ErrTargetRefMoved), letting callers branch on the cause
// without substring-matching Reason themselves. Re-exported publicly as
// gitsync.RefRejectedError.
// ...
Test Cases
func TestIsConcurrentMove(t *testing.T) {
cases := []struct {
name string
reason string
want bool
}{
{"entire-server CAS rejection", "remote ref has changed", true},
{"CAS rejection with surrounding detail", "command error on refs/heads/main: remote ref has changed", true},
{"force-with-lease stale info", "stale info", true},
{"stale info case-insensitive", "Stale Info", true},
{"plain non-fast-forward is ambiguous", "non-fast-forward", false},
{"fetch first is ambiguous", "fetch first", false},
{"does not match is ambiguous", "remote ref does not match expected old value", false},
{"policy rejection", "deny updating a hidden ref", false},
{"empty", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isConcurrentMove(c.reason); got != c.want {
t.Fatalf("isConcurrentMove(%q) = %v, want %v", c.reason, got, c.want)
}
})
}
}
func TestAsRefRejectedErrorClassifiesAndPreserves(t *testing.T) {
cases := []struct {
name string
status string
moved bool
}{
{"concurrent move (remote ref has changed)", "remote ref has changed", true},
{"lease miss (stale info)", "stale info", true},
{"ambiguous non-fast-forward is not a move", "non-fast-forward", false},
{"policy rejection is not a move", "deny updating a hidden ref", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cs := &packp.CommandStatusErr{ReferenceName: "refs/heads/main", Status: c.status}
wrapped := fmt.Errorf("sync: %w", fmt.Errorf("report-status: %w", asRefRejectedError(annotateLeaseFailure(cs))))
var rej *RefRejectedError
if !errors.As(wrapped, &rej) {
t.Fatalf("errors.As must reach *RefRejectedError through wrapping; got %v", wrapped)
}
if rej.Ref != "refs/heads/main" || rej.Reason != c.status {
t.Fatalf("Ref/Reason = %q/%q, want refs/heads/main and %q", rej.Ref, rej.Reason, c.status)
}
if got := errors.Is(wrapped, ErrTargetRefMoved); got != c.moved {
t.Fatalf("errors.Is(ErrTargetRefMoved) = %v, want %v (status=%q)", got, c.moved, c.status)
}
var cse *packp.CommandStatusErr
if !errors.As(wrapped, &cse) || cse.Status != c.status {
t.Fatalf("underlying *packp.CommandStatusErr must be preserved; got %#v", wrapped)
}
if !strings.Contains(wrapped.Error(), c.status) {
t.Fatalf("message must still contain the raw reason %q; got %q", c.status, wrapped.Error())
}
})
}
}