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

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

[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())
            }
        })
    }
}