mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* Fall back to a merge when a divergent pull has no reconciliation strateg - Git 2.27+ refuses `git pull` on divergent branches unless pull.rebase or pull.ff is configured. Retry with `--no-rebase` (Git's historical default) so pulls succeed out of the box on fresh hosts. - Skip the fallback whenever the caller already specified a reconciliation strategy (e.g. --ff-only, --rebase) so explicit policies still fail as expected on divergence. - Applied identically in the local git pull path and the relay/SSH git handler so both surfaces behave the same way. * Refactor divergent-pull merge fallback into shared helper Extracts the retry-as-merge logic (duplicated between local git and relay SSH pull paths) into `runPullWithDivergenceFallback` in git-remote-error.ts, so both callers share one implementation and test coverage.
236 lines
8.6 KiB
TypeScript
236 lines
8.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
formatSubmodulePushFailureDetail,
|
|
isDivergentPullReconciliationError,
|
|
isNoUpstreamError,
|
|
MERGE_RECONCILIATION_PULL_ARGS,
|
|
normalizeGitErrorMessage,
|
|
pullArgsSpecifyReconciliation,
|
|
runPullWithDivergenceFallback
|
|
} from './git-remote-error'
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('normalizeGitErrorMessage', () => {
|
|
it('keeps the submodule name when a recursive push is rejected', () => {
|
|
const error = new Error(
|
|
"Command failed: git push\nPushing submodule 'find-cmux-followers'\n" +
|
|
'To https://github.com/stablyai/orca-internal\n' +
|
|
' ! [rejected] master -> master (fetch first)\n' +
|
|
"Unable to push submodule 'find-cmux-followers'\n" +
|
|
'fatal: failed to push all needed submodules'
|
|
)
|
|
|
|
expect(normalizeGitErrorMessage(error, 'push')).toBe(
|
|
"Submodule 'find-cmux-followers' has remote changes. Pull inside the submodule, then try again."
|
|
)
|
|
})
|
|
|
|
it('preserves redacted pre-push hook output instead of the generic git tail line', () => {
|
|
const error = new Error(
|
|
[
|
|
'Command failed: git push https://x-access-token:ghp_secret@github.com/acme/repo.git HEAD',
|
|
'husky - pre-push hook failed',
|
|
'eslint found 2 errors',
|
|
"error: failed to push some refs to 'https://ghp_tailSecret@github.com/acme/repo.git'"
|
|
].join('\n')
|
|
)
|
|
|
|
const message = normalizeGitErrorMessage(error, 'push')
|
|
|
|
expect(message).toContain('husky - pre-push hook failed')
|
|
expect(message).toContain('eslint found 2 errors')
|
|
expect(message).toContain(
|
|
"error: failed to push some refs to 'https://github.com/acme/repo.git'"
|
|
)
|
|
expect(message).not.toContain('x-access-token')
|
|
expect(message).not.toContain('ghp_secret')
|
|
expect(message).not.toContain('ghp_tailSecret')
|
|
})
|
|
|
|
it('does not preserve remote pre-receive output as a push hook failure', () => {
|
|
const error = new Error(
|
|
[
|
|
'Command failed: git push origin main',
|
|
'remote: pre-receive hook declined',
|
|
'remote: eslint failed in hosted checks',
|
|
"error: failed to push some refs to 'origin'"
|
|
].join('\n')
|
|
)
|
|
|
|
expect(normalizeGitErrorMessage(error, 'push')).toBe(
|
|
"error: failed to push some refs to 'origin'"
|
|
)
|
|
})
|
|
|
|
it('explains how to configure a pull policy for divergent branches', () => {
|
|
const error = new Error(
|
|
'Command failed: git pull\n' +
|
|
'hint: You have divergent branches and need to specify how to reconcile them.\n' +
|
|
'fatal: Need to specify how to reconcile divergent branches.'
|
|
)
|
|
|
|
expect(normalizeGitErrorMessage(error, 'pull')).toBe(
|
|
'Pull needs a Git pull policy for divergent branches. Configure one for this repository ' +
|
|
'or host, then try again: git config pull.rebase false (merge), ' +
|
|
'git config pull.rebase true (rebase), or git config pull.ff only (fast-forward only).'
|
|
)
|
|
})
|
|
|
|
it('uses the tail diagnostic from newline-heavy failures without line-array splitting', () => {
|
|
const splitSpy = vi.spyOn(String.prototype, 'split')
|
|
const error = new Error(
|
|
`Command failed: git fetch\r\n${'remote: progress update\r\n'.repeat(10_000)}remote side closed connection\r\n`
|
|
)
|
|
|
|
expect(normalizeGitErrorMessage(error, 'fetch')).toBe('remote side closed connection')
|
|
|
|
const usedLineSplit = splitSpy.mock.calls.some(
|
|
([separator]) =>
|
|
(typeof separator === 'string' && separator === '\n') ||
|
|
(separator instanceof RegExp && separator.source === '\\r?\\n')
|
|
)
|
|
expect(usedLineSplit).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('formatSubmodulePushFailureDetail', () => {
|
|
it('keeps normalized guidance when transport layers prefix the error', () => {
|
|
expect(
|
|
formatSubmodulePushFailureDetail(
|
|
"Error invoking remote method 'git:push': Error: Submodule 'vendor/tools' has remote changes. Pull inside the submodule, then try again."
|
|
)
|
|
).toBe(
|
|
"Submodule 'vendor/tools' has remote changes. Pull inside the submodule, then try again."
|
|
)
|
|
})
|
|
|
|
it('falls back to submodule-specific guidance when git omits the nested reason', () => {
|
|
expect(
|
|
formatSubmodulePushFailureDetail(
|
|
"Unable to push submodule 'vendor/tools'\nfatal: failed to push all needed submodules"
|
|
)
|
|
).toBe(
|
|
"Submodule 'vendor/tools' could not be pushed. Resolve the submodule push error, then try again."
|
|
)
|
|
})
|
|
|
|
it('checks newline-heavy output without full CRLF normalization', () => {
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
const message = `${'remote: progress\r\n'.repeat(10_000)}Unable to push submodule 'vendor/tools'\r\nfatal: failed to push all needed submodules\r\n`
|
|
|
|
expect(formatSubmodulePushFailureDetail(message)).toBe(
|
|
"Submodule 'vendor/tools' could not be pushed. Resolve the submodule push error, then try again."
|
|
)
|
|
|
|
const usedCrlfReplace = replaceSpy.mock.calls.some(
|
|
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n'
|
|
)
|
|
expect(usedCrlfReplace).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isNoUpstreamError', () => {
|
|
it('treats a missing HEAD@{u} tracking ref as no upstream', () => {
|
|
const error = new Error(
|
|
"fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" +
|
|
"Use '--' to separate paths from revisions, like this:\n" +
|
|
"'git <command> [<revision>...] -- [<file>...]'"
|
|
)
|
|
|
|
expect(isNoUpstreamError(error)).toBe(true)
|
|
})
|
|
|
|
it('does not treat unrelated ambiguous refs as no upstream', () => {
|
|
const error = new Error(
|
|
"fatal: ambiguous argument 'feature': unknown revision or path not in the working tree."
|
|
)
|
|
|
|
expect(isNoUpstreamError(error)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isDivergentPullReconciliationError', () => {
|
|
it('detects git 2.27+ divergent-branch reconciliation failures', () => {
|
|
const error = new Error(
|
|
'Command failed: git pull\n' +
|
|
'hint: You have divergent branches and need to specify how to reconcile them.\n' +
|
|
'fatal: Need to specify how to reconcile divergent branches.'
|
|
)
|
|
|
|
expect(isDivergentPullReconciliationError(error)).toBe(true)
|
|
})
|
|
|
|
it('does not match a fast-forward-only abort on divergent branches', () => {
|
|
const error = new Error(
|
|
'Command failed: git pull\nfatal: Not possible to fast-forward, aborting.'
|
|
)
|
|
|
|
expect(isDivergentPullReconciliationError(error)).toBe(false)
|
|
})
|
|
|
|
it('returns false for non-Error values', () => {
|
|
expect(isDivergentPullReconciliationError('divergent branches')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('pullArgsSpecifyReconciliation', () => {
|
|
it('is false when no strategy flag is present', () => {
|
|
expect(pullArgsSpecifyReconciliation([])).toBe(false)
|
|
expect(pullArgsSpecifyReconciliation(['origin', 'main'])).toBe(false)
|
|
})
|
|
|
|
it('is true for any explicit reconciliation flag', () => {
|
|
expect(pullArgsSpecifyReconciliation(['--ff-only'])).toBe(true)
|
|
expect(pullArgsSpecifyReconciliation(['--rebase'])).toBe(true)
|
|
expect(pullArgsSpecifyReconciliation(['--no-rebase'])).toBe(true)
|
|
expect(pullArgsSpecifyReconciliation(['--rebase=interactive'])).toBe(true)
|
|
expect(pullArgsSpecifyReconciliation(['-r'])).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('runPullWithDivergenceFallback', () => {
|
|
const divergentError = new Error(
|
|
'Command failed: git pull\n' +
|
|
'hint: You have divergent branches and need to specify how to reconcile them.\n' +
|
|
'fatal: Need to specify how to reconcile divergent branches.'
|
|
)
|
|
|
|
it('retries with merge reconciliation args on a policy error', async () => {
|
|
const calls: string[][] = []
|
|
const runPull = vi.fn(async (effectiveArgs: string[]) => {
|
|
calls.push(effectiveArgs)
|
|
if (effectiveArgs.length === 0) {
|
|
throw divergentError
|
|
}
|
|
})
|
|
|
|
await runPullWithDivergenceFallback([], runPull)
|
|
|
|
expect(calls).toEqual([[], [...MERGE_RECONCILIATION_PULL_ARGS]])
|
|
expect(runPull).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not retry when pull args already specify reconciliation', async () => {
|
|
const runPull = vi.fn(async () => {
|
|
throw divergentError
|
|
})
|
|
|
|
await expect(runPullWithDivergenceFallback(['--ff-only'], runPull)).rejects.toBe(divergentError)
|
|
expect(runPull).toHaveBeenCalledTimes(1)
|
|
expect(runPull).toHaveBeenCalledWith(['--ff-only'])
|
|
})
|
|
|
|
it('rethrows non-divergence errors without retrying', async () => {
|
|
const otherError = new Error('fatal: Not possible to fast-forward, aborting.')
|
|
const runPull = vi.fn(async () => {
|
|
throw otherError
|
|
})
|
|
|
|
await expect(runPullWithDivergenceFallback([], runPull)).rejects.toBe(otherError)
|
|
expect(runPull).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|