diff --git a/src/shared/git-remote-error.test.ts b/src/shared/git-remote-error.test.ts index c2c3a6950ca..380cd05ae2c 100644 --- a/src/shared/git-remote-error.test.ts +++ b/src/shared/git-remote-error.test.ts @@ -13,6 +13,39 @@ afterEach(() => { }) describe('normalizeGitErrorMessage', () => { + it.each([ + ' ! [rejected] main -> main (stale info)', + '\t!\t[rejected] main -> main (stale info)\t', + ' \x1b[31m! [rejected] main -> main (stale info)\x1b[m', + '\x1b[31m ! [rejected] main -> main (stale info)\x1b[0m' + ])('preserves stale lease rejection details: %s', (rejection) => { + for (const newline of ['\n', '\r\n']) { + const output = [ + 'Command failed: git push https://user:secret@example.com/repo.git', + rejection, + "error: failed to push some refs to 'https://token@example.com/repo.git'" + ].join(newline) + + expect(normalizeGitErrorMessage(new Error(output), 'push')).toBe( + output.replace('user:secret@', '').replace('token@', '') + ) + } + }) + + it('does not classify a rejection marker on a separate line as a stale lease rejection', () => { + const tail = "error: failed to push some refs to 'origin'" + const error = new Error(`Command failed: git push\n !\n [rejected] main (stale info)\n${tail}`) + + expect(normalizeGitErrorMessage(error, 'push')).toBe(tail) + }) + + it('handles push errors containing many blank lines', () => { + const tail = "error: failed to push some refs to 'origin'" + const error = new Error(`Command failed: git push\n${'\n'.repeat(64_000)}${tail}`) + + expect(normalizeGitErrorMessage(error, 'push')).toBe(tail) + }) + it.each(['remote: stale info', 'hook: stale info'])( 'does not classify unrelated output as a lease rejection: %s', (output) => { diff --git a/src/shared/git-remote-error.ts b/src/shared/git-remote-error.ts index 4cbd58e1311..d95a6002223 100644 --- a/src/shared/git-remote-error.ts +++ b/src/shared/git-remote-error.ts @@ -1,3 +1,4 @@ +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' import { isPushHookFailure } from './source-control-push-failure' // Why: strip `user:password@` on any scheme, but a lone `user@` only on HTTP(S) — SSH's git@host user-info is required, so stripping breaks the URL. @@ -6,7 +7,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 STALE_LEASE_REJECTION_PATTERN = /^[ \t]*![ \t]+\[rejected\][^\r\n]*\(stale info\)[ \t]*$/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 = @@ -166,7 +167,10 @@ 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' && (STALE_LEASE_REJECTION_PATTERN.test(raw) || isPushHookFailure(raw))) { + if ( + operation === 'push' && + (STALE_LEASE_REJECTION_PATTERN.test(stripAnsiEscapeSequences(raw)) || isPushHookFailure(raw)) + ) { // Lease rejection and pre-push hook details precede Git's generic "failed to push some refs" tail line. return raw.trim() }