errors: expose ErrTargetRefMoved / RefRejectedError on the public API · Entire
errors: expose ErrTargetRefMoved / RefRejectedError on the public API
396fa25→main·
nodo·1mo ago·5 files·+220 added/-1 removed
Embedders that mirror into a target currently can only tell a benign racing concurrent push apart from a real push failure by substring-matching the free-form receive-pack error message. That is fragile: the git wire protocol carries no structured code for a report-status "ng" reason, so every consumer re-implements the same brittle matching, and broad markers like "non-fast-forward" are ambiguous (a legitimately non-fast-forward update that wasn't force-pushed looks identical to a race).
Give the classification a home in the library, where the protocol context lives. Sync/Replicate now report a typed *RefRejectedError (carrying the rejected Ref and the raw server Reason) for per-ref receive-pack rejections, reachable with errors.As. Rejections git-sync can prove are concurrent target-ref moves -- entire-server's compare-and-swap rejection ("remote ref has changed") and git's --force-with-lease lease miss ("stale info") -- additionally satisfy errors.Is(err, ErrTargetRefMoved).
The move classification is deliberately narrower than leaseFailureMarkers: "non-fast-forward" / "fetch first" are excluded so a real "needs --force" rejection is never silently treated as a benign, retryable race.
The typed error is layered over the existing annotateLeaseFailure wrap, so the message text and the underlying *packp.CommandStatusErr are preserved unchanged -- existing string and errors.As checks keep working, and IsLeaseFailure (used by the BestEffort path) is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Changes
5
MCHANGELOG.md+6
Aerrors.go+20
Aerrors_test.go+27
internal/gitproto
Mpush.go+86/-1
Mpush_test.go+81
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](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Typed push-rejection errors on the public API. `Sync`/`Replicate` now report a `*RefRejectedError` (carrying the rejected `Ref` and the raw server `Reason`) for per-ref receive-pack `ng` statuses, reachable with `errors.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-lease` lease miss (`stale info`) — additionally satisfy `errors.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. The error message and the underlying `*packp.CommandStatusErr` are preserved unchanged, so existing checks keep working.
## [0.6.0] - 2026-06-03
### Added
MCHANGELOG.md+6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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). The
// concrete error in the chain is a *RefRejectedError.
//
// 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
Aerrors.go+20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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")
}
}
Aerrors_test.go+27
138 unmodified lines
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
45 unmodified lines
275
276
277
193
278
279
280
281
138 unmodified lines
return fmt.Errorf("%w (target ref %s moved or differs from session start; rerun, or use --force-blind to overwrite)", err, cs.ReferenceName)
}
// 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.
type RefRejectedError struct {
Ref string // the rejected ref, e.g. "refs/heads/main"
Reason string // raw receive-pack ng reason, e.g. "remote ref has changed"
moved bool // git-sync's mode-independent judgment: an unambiguous concurrent move
err error // underlying error; preserves *packp.CommandStatusErr (+ any lease-hint annotation)
}
func (e *RefRejectedError) Error() string { return e.err.Error() }
// Unwrap exposes the underlying receive-pack error so existing
// errors.As(*packp.CommandStatusErr) checks — and substring inspection of the
// message — keep working byte-for-byte for callers that have not migrated.
func (e *RefRejectedError) Unwrap() error { return e.err }
// Is matches ErrTargetRefMoved only when this rejection is a concurrent
target-ref move. Other rejections remain reachable via errors.As but are not
// ErrTargetRefMoved.
func (e *RefRejectedError) Is(target error) bool {
return target == ErrTargetRefMoved && e.moved
}
// concurrentMoveMarkers are receive-pack ng reasons that UNAMBIGUOUSLY mean the
// target ref changed under us between plan and push — a clean compare-and-swap /
// lease miss that a plain retry resolves. Deliberately NARROWER than
// leaseFailureMarkers: "non-fast-forward" / "fetch first" are excluded because an
// update that is legitimately non-fast-forward and wasn't force-pushed looks
// identical to a race, and treating it as a benign move would mask a real
// "needs --force" failure. "remote ref has changed" is entire-server's
// compare-and-swap rejection (storage.ErrReferenceHasChanged); "stale info" is
// git's --force-with-lease lease miss. Both mean the server's actual tip differed
// from the expected-old value this run sent. Match is case-insensitive substring
// (Reason is free-form; see RefRejectedError).
var concurrentMoveMarkers = []string{
"remote ref has changed",
"stale info",
}
// isConcurrentMove reports whether a receive-pack ng reason is an unambiguous
// concurrent target-ref move (see concurrentMoveMarkers).
func isConcurrentMove(reason string) bool {
lowered := strings.ToLower(reason)
for _, marker := range concurrentMoveMarkers {
if strings.Contains(lowered, marker) {
return true
}
}
return false
}
// asRefRejectedError wraps a target receive-pack report-status "ng" error in
// a typed *RefRejectedError so callers can branch on errors.As /
// errors.Is(err, ErrTargetRefMoved) instead of substring-matching the free-form
// reason themselves. Inputs that are not a per-ref command status (e.g. an
// unpack-status error) pass through unchanged. The input is preserved via Unwrap,
// so the message and any errors.As(*packp.CommandStatusErr) check are unchanged.
func asRefRejectedError(err error) error {
var cs *packp.CommandStatusErr
if !errors.As(err, &cs) {
return err
}
return &RefRejectedError{
Ref: cs.ReferenceName.String(),
Reason: cs.Status,
moved: isConcurrentMove(cs.Status),
err: err,
}
}
// sendReceivePack encodes and POSTs a receive-pack request, then decodes the report.
func sendReceivePack(
ctx context.Context,
45 unmodified lines
}
}
if onRejection == nil {
if err := report.Error(); err != nil {
return fmt.Errorf("report-status: %w", annotateLeaseFailure(err))
return fmt.Errorf("report-status: %w", asRefRejectedError(annotateLeaseFailure(err)))
}
return nil
}
}`