# Merge pull request \#71 from entireio/errors/target-ref-moved

`92066ee`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·  
  
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](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 `ForceWithLease` lease-failure escalation (raised even under `BestEffort`) also satisfies `errors.Is(err, ErrTargetRefMoved)`, though it is not itself a `*RefRejectedError` — prefer `errors.Is` over `errors.As` when you only need the cause. 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
```

```go
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
```

```go
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")
	}
}
```

```go
// 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

```go
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)
			}
		})
	}
}
```

```go
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())
			}
		})
	}
}
```
