From e182f1daa2d26e0bd1ccf1c92af033b4651bb513 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:59:55 -0700 Subject: [PATCH] Preserve force-with-lease rejection details in push errors (#36027) --- .../slices/editor-remote-branch-actions.test.ts | 15 ++++++++++++++- src/shared/git-remote-error.test.ts | 10 ++++++++++ src/shared/git-remote-error.ts | 5 +++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts index d1d3a1db876..bb06f0c8525 100644 --- a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts +++ b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createEditorStore, flushAsyncRemoteRefresh } from './editor-slice-test-harness' import { isSyncPushStageError } from '@/lib/source-control-remote-error' +import { normalizeGitErrorMessage } from '../../../../shared/git-remote-error' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -537,7 +538,19 @@ describe('createEditorSlice remote branch actions', () => { it('maps force-with-lease rejection into force-push guidance', async () => { const store = createEditorStore() - const pushError = new Error('fatal: stale info') + const pushError = new Error( + normalizeGitErrorMessage( + new Error( + [ + 'Command failed: git push --force-with-lease --set-upstream origin HEAD:feature', + 'To https://github.com/acme/repo.git', + ' ! [rejected] HEAD -> feature (stale info)', + "error: failed to push some refs to 'https://github.com/acme/repo.git'" + ].join('\n') + ), + 'push' + ) + ) gitPushMock.mockRejectedValueOnce(pushError) await expect( diff --git a/src/shared/git-remote-error.test.ts b/src/shared/git-remote-error.test.ts index d4808f86618..c2c3a6950ca 100644 --- a/src/shared/git-remote-error.test.ts +++ b/src/shared/git-remote-error.test.ts @@ -13,6 +13,16 @@ afterEach(() => { }) describe('normalizeGitErrorMessage', () => { + it.each(['remote: stale info', 'hook: stale info'])( + 'does not classify unrelated output as a lease rejection: %s', + (output) => { + const tail = "error: failed to push some refs to 'origin'" + const error = new Error(`Command failed: git push\n${output}\n${tail}`) + + expect(normalizeGitErrorMessage(error, 'push')).toBe(tail) + } + ) + it.each(['push', 'pull', 'fetch', 'upstream'] as const)( 'classifies noninteractive credential refusal for %s', (operation) => { diff --git a/src/shared/git-remote-error.ts b/src/shared/git-remote-error.ts index c14facd6d7c..4cbd58e1311 100644 --- a/src/shared/git-remote-error.ts +++ b/src/shared/git-remote-error.ts @@ -6,6 +6,7 @@ const HTTPS_TOKEN_URL_PATTERN = /(https?:\/\/)[^\s/@:]+@/gi const SUBMODULE_PUSH_FAILURE_PATTERN = /Unable to push submodule ['"](.+?)['"]/i const SUBMODULE_PUSH_FAILURE_SENTINEL_PATTERN = /failed to push all needed submodules|Unable to push submodule/i +const STALE_LEASE_REJECTION_PATTERN = /^\s*!\s+\[rejected\][^\r\n]*\(stale info\)\s*$/m const SUBMODULE_REMOTE_CHANGED_PATTERN = /non-fast-forward|fetch first|updates were rejected|remote contains work that you do not have/i const NORMALIZED_SUBMODULE_PUSH_FAILURE_PATTERN = @@ -165,8 +166,8 @@ export function normalizeGitErrorMessage(error: unknown, operation?: GitRemoteOp return 'Push rejected: remote has newer commits (non-fast-forward). Please pull or sync first.' } - if (operation === 'push' && isPushHookFailure(raw)) { - // Why: pre-push hook output is the actionable part, printed before git's generic "failed to push some refs" tail line. + if (operation === 'push' && (STALE_LEASE_REJECTION_PATTERN.test(raw) || isPushHookFailure(raw))) { + // Lease rejection and pre-push hook details precede Git's generic "failed to push some refs" tail line. return raw.trim() }