errors: address review — nil-safe Error, lease-path sentinel, doc caveats · Entire

errors: address review — nil-safe Error, lease-path sentinel, doc caveats

655c91cmain·

nodo·1mo ago·6 files·+67 added/-10 removed

Follow-up to review feedback on the ErrTargetRefMoved / RefRejectedError API:

Changes

6

Added

[0.6.0] - 2026-06-03

// 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. // 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,

// satisfy errors.Is(err, ErrTargetRefMoved), letting callers branch on the cause // without substring-matching Reason themselves. Re-exported publicly as // gitsync.RefRejectedError. // // When a single push rejects multiple refs, report-status surfaces the first // failing ref (go-git follows canonical git here), so Ref/Reason reflect that // first ref; any others resurface on the next attempt. The ErrTargetRefMoved // classification cannot be reproduced by external construction (the deciding // field is unexported by design) — to exercise errors.Is in a downstream test, // wrap the sentinel directly: fmt.Errorf("...: %w", gitsync.ErrTargetRefMoved). 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"

err   error // underlying error; preserves *packp.CommandStatusErr (+ any lease-hint annotation)

}

func (e *RefRejectedError) Error() string { return e.err.Error() } // Error is safe on a zero-value/externally-constructed RefRejectedError (one // with no wrapped err), so embedders can build &RefRejectedError{Ref, Reason} // in tests without a nil panic. func (e *RefRejectedError) Error() string { if e.err == nil { return fmt.Sprintf("ref %s rejected: %s", e.Ref, e.Reason) } return e.err.Error() } // Unwrap exposes the underlying receive-pack error so existing // errors.As(*packp.CommandStatusErr) checks — and substring inspection of the // 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). // "needs --force" failure.

// This set is server-specific by design. "remote ref has changed" is // entire-server's compare-and-swap rejection (storage.ErrReferenceHasChanged) — // the one git-sync's own targets emit, and the case that matters in practice. // "stale info" is git's force-with-lease lease-miss phrasing, kept for // consistency with leaseFailureMarkers and defence-in-depth; note it is // primarily a client-side status, so it may not arrive as a server ng reason on // every target. Stock git servers phrase a CAS miss differently again // ("failed to update ref" / "cannot lock ref ... but expected ..."), which this // set does NOT match — so against a non-entire target a genuine race may fall // through to a plain rejection. Extend this set as new server phrasings are // observed. Match is case-insensitive substring (Reason is free-form; see // RefRejectedError). var concurrentMoveMarkers = []string{ "remote ref has changed", "stale info", }