fix(checkpoint): ssh-agent hint on git-refs BatchMode auth failure · Entire

fix(checkpoint): ssh-agent hint on git-refs BatchMode auth failure

960335f→main·

suhaanthayyil·2d ago·3 files·+37 added/-4 removed

Surface the same actionable hint from flushCheckpointRefsQueue that doPushRef prints on the v1 path, and skip useless per-ref recovery when SSH auth failed under non-interactive BatchMode.

Co-authored-by: Cursor cursoragent@cursor.com

Changes

3

359 unmodified lines

360
361
362
363
363
364
365
366
367
3 unmodified lines

371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
7 unmodified lines

394
395
396
397
398
399
400
401
402

359 unmodified lines

// 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, pushTarget, existing); err == nil {
        batchErr := batchPushRefs(pushCtx, pushTarget, existing)
        if batchErr == nil {
            stop(" done")
            if removeErr := queue.Remove(existing); removeErr != nil {
                logging.Warn(ctx, "git-refs push: clear pushed refs from queue failed",
            }
        }
        stop("")

// Non-interactive SSH auth failures cannot be fixed by per-ref
        // fetch+replay. Surface the same actionable hint as the v1 doPushRef path
        // (issue #1523) instead of only logging to .entire/logs/.
        if nonInteractiveSSHAuthFailure(pushCtx, batchErr) {
            fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push checkpoint refs: %v\n", batchErr)
            printNonInteractiveSSHAuthHint()
            printCheckpointRemoteHint(pushTarget)
            return 0, batchErr
        }

// 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
        7 unmodified lines

if err := pushCheckpointRefWithRecovery(pushCtx, pushTarget, ref); err != nil {
            logging.Warn(ctx, "git-refs push: checkpoint ref push/sync failed; left queued, not overwritten",
                slog.String("ref", ref.String()), slog.String("error", err.Error()))
            if nonInteractiveSSHAuthFailure(pushCtx, err) {
                printNonInteractiveSSHAuthHint()
            }
            if firstErr == nil {
                firstErr = err
            }
        }

Mcmd/entire/cli/strategy/manual_commit_push.go+15/-1

180 unmodified lines

181
182
183
184
184
185
186
187
13 unmodified lines

201
202
203
204
204
205
206
207
8 unmodified lines

216
217
218
219
219
220
221
222
13 unmodified lines

236
237
238
239
240
241
242
243
244
245
246
247
248

180 unmodified lines

// Non-interactive SSH (pre-push BatchMode): auth failures cannot be fixed by
    // fetch+rebase, and retrying would just reprint the same opaque error.
    // Surface an actionable ssh-agent hint and skip recovery (issue #1523).
    if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error()) {
        if nonInteractiveSSHAuthFailure(ctx, err) {
            fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push %s: %v\n", refLabel, err)
            printNonInteractiveSSHAuthHint()
            printCheckpointRemoteHint(target)
        }
    }

if syncErr != nil {
        stop("")
        fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", refLabel, syncErr)
        if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(syncErr.Error()) {
            if nonInteractiveSSHAuthFailure(ctx, syncErr) {
                printNonInteractiveSSHAuthHint()
            }
            printCheckpointRemoteHint(target)
        }
    }

if result, err := tryPushRefCommon(ctx, target, ref); err != nil {
        stop("")
        fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", refLabel, err)
        if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error()) {
            if nonInteractiveSSHAuthFailure(ctx, err) {
                printNonInteractiveSSHAuthHint()
            }
            printCheckpointRemoteHint(target)
        }
    }

return ref.String()
}

// nonInteractiveSSHAuthFailure reports whether err is an SSH auth-shaped
// failure under a BatchMode (non-interactive) context. Used to print the
// actionable ssh-agent hint and skip useless recovery retries.
func nonInteractiveSSHAuthFailure(ctx context.Context, err error) bool {
    return err != nil && remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error())
}

// printCheckpointRemoteHint prints a hint when a push to a checkpoint URL fails.
// Only prints when the target is a URL (not the user's default remote).
func printCheckpointRemoteHint(target string) {

Mcmd/entire/cli/strategy/push_common.go+10/-3

12 unmodified lines

13
14
15
16
17
18
19
1670 unmodified lines

1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703

12 unmodified lines

"testing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/testutil"

1670 unmodified lines

assert.Contains(t, out, "Checkpoint push skipped")
    assert.Equal(t, 1, strings.Count(out, "Checkpoint push skipped"), "hint must print once")
}

func TestNonInteractiveSSHAuthFailure(t *testing.T) {

t.Parallel()
    authErr := errors.New("git push: Permission denied (publickey).")
    ctx := remote.WithNonInteractiveSSH(context.Background())
    assert.True(t, nonInteractiveSSHAuthFailure(ctx, authErr))
    assert.False(t, nonInteractiveSSHAuthFailure(context.Background(), authErr),
        "interactive context must not treat auth errors as BatchMode hints")
    assert.False(t, nonInteractiveSSHAuthFailure(ctx, errors.New("non-fast-forward")))
    assert.False(t, nonInteractiveSSHAuthFailure(ctx, nil))
}