Recover a diverged checkpoint ref by fetch+replay instead of leaving it queued · Entire

Recover a diverged checkpoint ref by fetch+replay instead of leaving it queued

180f5b8·

Soph·2w ago·3 files·+115 added/-14 removed

Build on the non-force push: when a per-checkpoint ref is rejected (it diverged on the remote — the same checkpoint re-written elsewhere), instead of just leaving it queued, fetch the remote ref and replay the local-only commits on top via the existing fetchAndRebaseRefCommon, then retry. The retry stays non-force (after the replay the local ref is a fast-forward over the remote), so the remote's commit is preserved as an ancestor, never overwritten. The cherry-pick is delta-based, so non-overlapping changes merge; a genuine overlap (both sides rewrote the same file) surfaces as a rebase error and the ref is left for a later pre-push — degrading to the previous safe behavior, never to a force overwrite.

pre-push keeps the batch push as the fast path (one round-trip when everything fast-forwards) and falls back to this per-ref recovery only for rejected refs, removing from the queue just those that land. Adds a test proving a diverged ref ends up with both the remote-only and local-only changes after recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Changes

3

6 unmodified lines

7
8
9
10
11
12
13
14
151 unmodified lines

166
167
168
167
168
169
170
171
172
173
174
175
169
170
171
172
173
174
175
176
177
178
179
180
178
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199

6 unmodified lines

"log/slog"
    "os"

"github.com/go-git/go-git/v6/plumbing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
    "github.com/entireio/cli/cmd/entire/cli/logging"
    "github.com/entireio/cli/cmd/entire/cli/settings"
151 unmodified lines

}

pushCtx, pushSpan := perf.Start(ctx, "push_checkpoint_refs")
    pushErr := batchPushRefs(pushCtx, ps.pushTarget(), existing)
    pushSpan.End()
    if pushErr != nil {
        // Leave the refs queued; the next pre-push retries. Non-fatal so the
        // user's push proceeds. The push is fast-forward-only, so this can mean a
        // checkpoint ref diverged on the remote (non-fast-forward) — we leave it
        // queued rather than force-overwriting it.
        logging.Warn(ctx, "git-refs pre-push: checkpoint ref push failed (possible non-fast-forward divergence); refs left queued, not overwritten",
            slog.String("error", pushErr.Error()))
        defer pushSpan.End()

// Fast path: push all refs in one round-trip (fast-forward-only). If every
    // ref was up to date or fast-forwarded, we're done.
    if err := batchPushRefs(pushCtx, ps.pushTarget(), existing); err == nil {
        if removeErr := queue.Remove(existing); removeErr != nil {
            logging.Warn(ctx, "git-refs pre-push: clear pushed refs from queue failed",
                slog.String("error", removeErr.Error()))
        }
        cleanupPushedShadowBranches(ctx)
        return nil
    }
    if err := queue.Remove(existing); err != nil {

// At least one ref was rejected — typically a non-fast-forward divergence
    // (the same checkpoint re-written on another machine). Retry per ref with
    // fetch+replay recovery, and remove from the queue only the refs that land
    // (a genuine cherry-pick conflict leaves that ref queued for a later push,
    // never force-overwriting the remote).
    pushed := make([]plumbing.ReferenceName, 0, len(existing))
    for _, ref := range existing {
        if err := pushCheckpointRefWithRecovery(pushCtx, ps.pushTarget(), ref); err != nil {
            logging.Warn(ctx, "git-refs pre-push: checkpoint ref push/sync failed; left queued, not overwritten",
                slog.String("ref", ref.String()), slog.String("error", err.Error()))
            continue
        }
        pushed = append(pushed, ref)
    }
    if err := queue.Remove(pushed); err != nil {
        logging.Warn(ctx, "git-refs pre-push: clear pushed refs from queue failed",
            slog.String("error", err.Error()))
    }

Mcmd/entire/cli/strategy/manual_commit_push.go+28/-10

44 unmodified lines

45
46
47
48
49
50
51
48
49
50
51
52
53
8 unmodified lines

62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

44 unmodified lines

// differently on another machine — is REJECTED rather than silently overwriting
// the remote. We deliberately do not force: there is no server-side ref
// protection, so a force push would make a buggy or racing client clobber good
// remote history with no signal. On rejection the whole push errors and the
// caller leaves the refs queued (not overwritten) for a later pre-push;
// reconciling a genuinely diverged ref is deferred (a future rewrite path, e.g.
// OPF, would use --force-with-lease for the cases that must replace a ref).
// remote history with no signal. On rejection the whole push errors; the caller
// retries the rejected refs individually with fetch+replay recovery
// (pushCheckpointRefWithRecovery).
func batchPushRefs(ctx context.Context, target string, refs []plumbing.ReferenceName) error {
    if len(refs) == 0 {
        return nil
    }
8 unmodified lines

return nil
}

// pushCheckpointRefWithRecovery pushes a single checkpoint ref fast-forward-only;
// on rejection — typically the ref diverged on the remote (the same checkpoint
// re-written elsewhere) — it fetches the remote ref and replays the local-only
// commits on top via fetchAndRebaseRefCommon, then retries. The retry is still
// non-force: after the replay the local ref is a fast-forward over the remote, so
// the remote commit is preserved as an ancestor rather than overwritten. The
// cherry-pick is delta-based, so non-overlapping changes merge; a genuine overlap
// (e.g. both sides rewrote the root metadata.json) surfaces as a rebase error and
// the ref is left for a later pre-push. Returns nil only if the ref reached the
// remote.
func pushCheckpointRefWithRecovery(ctx context.Context, target string, ref plumbing.ReferenceName) error {
    // One shared budget across the initial push, fetch+replay, and retry, matching
    // doPushRef (fetchAndRebaseRefCommon relies on the caller's deadline).
    ctx, cancel := context.WithTimeout(ctx, checkpointPushBudget)
    defer cancel()

if err := batchPushRefs(ctx, target, []plumbing.ReferenceName{ref}); err == nil {
        return nil
    }
    if err := fetchAndRebaseRefCommon(ctx, target, ref); err != nil {
        return fmt.Errorf("sync diverged checkpoint ref %s: %w", ref, err)
    }
    return batchPushRefs(ctx, target, []plumbing.ReferenceName{ref})
}

// pushRefIfNeeded pushes a ref to the given target if it has unpushed changes.
// The target can be a remote name (e.g., "origin") or a URL for direct push.
// For branch refs, the "has unpushed" optimization consults the remote-tracking

Mcmd/entire/cli/strategy/push_common.go+28/-4

151 unmodified lines

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

151 unmodified lines

"remote ref must be unchanged after a rejected non-fast-forward push")
}

// TestPushCheckpointRefWithRecovery_MergesDivergedRef: when a checkpoint ref has
// diverged on the remote (the same checkpoint advanced differently elsewhere), the
// recovery fetches the remote tip and replays the local-only commit on top, so the
// retry is a fast-forward — preserving the remote's change instead of overwriting
// it. Non-overlapping changes merge.
func TestPushCheckpointRefWithRecovery_MergesDivergedRef(t *testing.T) {
    workDir, bareDir, refs := setupRepoWithCheckpointRefs(t)
    t.Chdir(workDir)
    ctx := context.Background()
    ref := refs[0]

repo, err := git.PlainOpen(workDir)
    require.NoError(t, err)
    head := func() plumbing.Hash {
        h, e := repo.Head()
        require.NoError(t, e)
        return h.Hash()
    }
    setRef := func(h plumbing.Hash) {
        require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(ref, h)))
    }

c1 := head()
    require.NoError(t, batchPushRefs(ctx, bareDir, []plumbing.ReferenceName{ref})) // remote ref = C1

// Remote advances: C2 (child of C1) adds b.txt; point the ref at it and push.
    testutil.WriteFile(t, workDir, "b.txt", "b")
    testutil.GitAdd(t, workDir, "b.txt")
    testutil.GitCommit(t, workDir, "add b")
    setRef(head())
    require.NoError(t, batchPushRefs(ctx, bareDir, []plumbing.ReferenceName{ref})) // remote ref = C2

// Local diverges: reset to C1 and make C3 (sibling of C2) adding c.txt.
    testutil.GitReset(t, workDir, c1.String())
    testutil.WriteFile(t, workDir, "c.txt", "c")
    testutil.GitAdd(t, workDir, "c.txt")
    testutil.GitCommit(t, workDir, "add c")
    setRef(head())

// C3 is not a descendant of the remote's C2 → the plain push is rejected and
    // recovery replays C3's delta onto C2.
    require.NoError(t, pushCheckpointRefWithRecovery(ctx, bareDir, ref),
        "diverged ref should be recovered by fetch+replay, not rejected")

files := remoteRefFiles(t, bareDir, ref)
    assert.Contains(t, files, "b.txt", "remote-only change must be preserved (not overwritten)")
    assert.Contains(t, files, "c.txt", "local-only change must be replayed on top")
}

// remoteRefFiles lists the files in the tree a ref points at on the bare remote.
func remoteRefFiles(t *testing.T, bareDir string, ref plumbing.ReferenceName) string {
    t.Helper()
    c := exec.CommandContext(context.Background(), "git", "-C", bareDir, "ls-tree", "-r", "--name-only", ref.String())
    c.Env = testutil.GitIsolatedEnv()
    out, err := c.CombinedOutput()
    require.NoError(t, err, "ls-tree failed: %s", out)
    return string(out)
}

// remoteRefHash returns the object hash a ref points at on the bare remote.
func remoteRefHash(t *testing.T, bareDir string, ref plumbing.ReferenceName) string {
    t.Helper()