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.
This commit is contained in:
Jinjing
2026-07-13 01:39:31 -07:00
parent e98bfd67c1
commit 3d557fc939
6 changed files with 202 additions and 31 deletions
+39
View File
@@ -363,6 +363,45 @@ describe('git remote operations', () => {
])
})
it('retries a divergent pull as a merge when no strategy is configured', async () => {
const divergentError = new Error(
'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.'
)
gitExecFileAsyncMock
// First attempt: plain pull rejects with git's reconciliation error.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(divergentError)
// Fallback attempt: pull --no-rebase (merge) succeeds.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPull('/repo')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }],
[['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }],
[['pull'], { cwd: '/repo' }],
[['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }],
[['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }],
[['pull', '--no-rebase'], { cwd: '/repo' }]
])
})
it('does not retry a fast-forward-only pull that fails on divergence', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(
new Error('Command failed: git pull\nfatal: Not possible to fast-forward, aborting.')
)
await expect(gitFastForward('/repo')).rejects.toThrow('Not possible to fast-forward')
// No fallback attempt: only the three probe/pull calls ran.
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(3)
})
it('pulls the same-name origin branch for legacy base-tracking worktrees', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
+26 -5
View File
@@ -1,4 +1,9 @@
import { normalizeGitErrorMessage } from '../../shared/git-remote-error'
import {
MERGE_RECONCILIATION_PULL_ARGS,
isDivergentPullReconciliationError,
normalizeGitErrorMessage,
pullArgsSpecifyReconciliation
} from '../../shared/git-remote-error'
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source'
@@ -214,11 +219,11 @@ async function gitPullWithArgs(
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
try {
const runPull = async (effectiveArgs: string[]): Promise<void> => {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
await gitExecFileAsync(
['pull', ...pullArgs, target.remoteName, target.branchName],
['pull', ...effectiveArgs, target.remoteName, target.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
@@ -230,13 +235,29 @@ async function gitPullWithArgs(
// Why: legacy Orca branches may still track origin/main while pushes
// target origin/<branch>. Pull the same effective branch the UI reports.
await gitExecFileAsync(
['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
}
await gitExecFileAsync(['pull', ...pullArgs], gitOptionsForWorktree(worktreePath, options))
await gitExecFileAsync(['pull', ...effectiveArgs], gitOptionsForWorktree(worktreePath, options))
}
try {
try {
await runPull(pullArgs)
} catch (error) {
// Why: on hosts with no pull.rebase/pull.ff policy, Git 2.27+ refuses to
// reconcile divergent branches. Retry as a merge (Git's historical
// default) so pulls succeed out of the box; callers that already forced a
// strategy — or users who configured rebase — never reach this fallback.
if (!pullArgsSpecifyReconciliation(pullArgs) && isDivergentPullReconciliationError(error)) {
await runPull([...MERGE_RECONCILIATION_PULL_ARGS, ...pullArgs])
return
}
throw error
}
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'pull'))
}