mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +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.
This commit is contained in:
@@ -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
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -86,13 +86,33 @@ describe('GitHandler pull reconciliation', () => {
|
||||
return consumerDir
|
||||
}
|
||||
|
||||
it('explains how to configure a pull policy when divergent branches have no strategy', async () => {
|
||||
it('falls back to a merge when divergent branches have no configured strategy', async () => {
|
||||
const consumerDir = createDivergentFixture()
|
||||
|
||||
await expect(dispatcher.callRequest('git.pull', { worktreePath: consumerDir })).rejects.toThrow(
|
||||
'Pull needs a Git pull policy for divergent branches. Configure one for this repository'
|
||||
)
|
||||
await expect(
|
||||
dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
|
||||
).resolves.not.toThrow()
|
||||
|
||||
// Merge reconciliation yields a two-parent commit and both sides' files.
|
||||
const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
|
||||
expect(parentRefs).toHaveLength(2)
|
||||
expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true)
|
||||
expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true)
|
||||
expect(execGit(consumerDir, ['status', '--short'])).toBe('')
|
||||
}, 15_000)
|
||||
|
||||
it('preserves configured fast-forward-only pull semantics on divergent branches', async () => {
|
||||
const consumerDir = createDivergentFixture()
|
||||
execGit(consumerDir, ['config', 'pull.ff', 'only'])
|
||||
|
||||
// Why: an explicit ff-only policy must still fail on divergence rather than
|
||||
// getting silently reconciled by the merge fallback.
|
||||
await expect(
|
||||
dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
|
||||
).rejects.toThrow()
|
||||
|
||||
const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
|
||||
expect(parentRefs).toHaveLength(1)
|
||||
expect(execGit(consumerDir, ['status', '--short'])).toBe('')
|
||||
}, 15_000)
|
||||
|
||||
|
||||
+46
-21
@@ -44,7 +44,13 @@ import { gitExecMutatesRepository } from '../shared/git-exec-mutation'
|
||||
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { checkIgnoredPathsOp } from './git-handler-check-ignore'
|
||||
import { resolveRelayPushTarget } from './git-handler-push-target'
|
||||
import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error'
|
||||
import {
|
||||
MERGE_RECONCILIATION_PULL_ARGS,
|
||||
isDivergentPullReconciliationError,
|
||||
isNoUpstreamError,
|
||||
normalizeGitErrorMessage,
|
||||
pullArgsSpecifyReconciliation
|
||||
} from '../shared/git-remote-error'
|
||||
import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status'
|
||||
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
|
||||
import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status'
|
||||
@@ -1021,29 +1027,48 @@ export class GitHandler {
|
||||
private async pullWithArgs(params: Record<string, unknown>, pullArgs: string[]) {
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const runPull = async (effectiveArgs: string[]): Promise<void> => {
|
||||
if (params.pushTarget !== undefined) {
|
||||
assertGitPushTargetShape(params.pushTarget)
|
||||
const pushTarget = params.pushTarget as GitPushTarget
|
||||
await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
|
||||
await this.git(
|
||||
['pull', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName],
|
||||
worktreePath
|
||||
)
|
||||
return
|
||||
}
|
||||
const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
|
||||
if (upstream && !upstream.isConfiguredUpstream) {
|
||||
// Why: legacy Orca branches may still track origin/main while pushes
|
||||
// target origin/<branch>. Pull the same effective branch the UI reports.
|
||||
await this.git(
|
||||
['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
|
||||
worktreePath
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.git(['pull', ...effectiveArgs], worktreePath)
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
if (params.pushTarget !== undefined) {
|
||||
assertGitPushTargetShape(params.pushTarget)
|
||||
const pushTarget = params.pushTarget as GitPushTarget
|
||||
await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
|
||||
await this.git(
|
||||
['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName],
|
||||
worktreePath
|
||||
)
|
||||
return
|
||||
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 SSH pulls succeed out of the box; forced strategies and
|
||||
// rebase-configured users never reach this fallback.
|
||||
if (
|
||||
!pullArgsSpecifyReconciliation(pullArgs) &&
|
||||
isDivergentPullReconciliationError(error)
|
||||
) {
|
||||
await runPull([...MERGE_RECONCILIATION_PULL_ARGS, ...pullArgs])
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
|
||||
if (upstream && !upstream.isConfiguredUpstream) {
|
||||
// Why: legacy Orca branches may still track origin/main while pushes
|
||||
// target origin/<branch>. Pull the same effective branch the UI reports.
|
||||
await this.git(
|
||||
['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
|
||||
worktreePath
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.git(['pull', ...pullArgs], worktreePath)
|
||||
} catch (error) {
|
||||
// Why: mirror the local gitPull normalization so SSH users see the same
|
||||
// actionable messages instead of raw git stderr.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
formatSubmodulePushFailureDetail,
|
||||
isDivergentPullReconciliationError,
|
||||
isNoUpstreamError,
|
||||
normalizeGitErrorMessage
|
||||
normalizeGitErrorMessage,
|
||||
pullArgsSpecifyReconciliation
|
||||
} from './git-remote-error'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -147,3 +149,42 @@ describe('isNoUpstreamError', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,15 @@ const NORMALIZED_SUBMODULE_PUSH_FAILURE_PATTERN =
|
||||
/(?:^|:\s)((?:Submodule '[^'\n]+'|A submodule) (?:has remote changes\. Pull inside the submodule, then try again\.|could not be pushed\. Resolve the submodule push error, then try again\.))(?:$|\s)/i
|
||||
const DIVERGENT_PULL_RECONCILIATION_PATTERN =
|
||||
/Need to specify how to reconcile divergent branches|divergent branches and need to specify how to reconcile them/i
|
||||
// Why: any of these already pin a reconciliation strategy, so the merge
|
||||
// fallback must not override an explicit caller/user choice (e.g. --ff-only).
|
||||
const RECONCILIATION_PULL_ARG_PATTERN =
|
||||
/^(--rebase|--no-rebase|--ff-only|--ff|--no-ff|--merge|-r)(=|$)/
|
||||
// Why: merge is Git's historical pre-2.27 default. Falling back to it lets
|
||||
// divergent pulls reconcile on fresh hosts that never configured pull.rebase
|
||||
// or pull.ff, instead of failing outright. `--no-rebase` predates the 2.25
|
||||
// baseline, so it is safe across all supported Git binaries.
|
||||
export const MERGE_RECONCILIATION_PULL_ARGS = ['--no-rebase']
|
||||
|
||||
export function stripCredentialsFromMessage(message: string): string {
|
||||
return message.replace(USERPASS_URL_PATTERN, '$1').replace(HTTPS_TOKEN_URL_PATTERN, '$1')
|
||||
@@ -81,6 +90,22 @@ function* iterateLinesFromEnd(value: string): Generator<string> {
|
||||
yield value.slice(0, lineEnd)
|
||||
}
|
||||
|
||||
// Why: a fresh host may lack any pull.rebase/pull.ff policy, so Git 2.27+
|
||||
// refuses to reconcile divergent branches. Detect that specific failure so the
|
||||
// caller can retry with an explicit merge instead of surfacing a config error.
|
||||
export function isDivergentPullReconciliationError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
return DIVERGENT_PULL_RECONCILIATION_PATTERN.test(stripCredentialsFromMessage(error.message))
|
||||
}
|
||||
|
||||
// Whether the pull already specifies how to reconcile (rebase/merge/ff-only),
|
||||
// in which case the caller's choice must win over the merge fallback.
|
||||
export function pullArgsSpecifyReconciliation(pullArgs: string[]): boolean {
|
||||
return pullArgs.some((arg) => RECONCILIATION_PULL_ARG_PATTERN.test(arg))
|
||||
}
|
||||
|
||||
export type GitRemoteOperation = 'push' | 'pull' | 'fetch' | 'upstream'
|
||||
|
||||
export function normalizeGitErrorMessage(error: unknown, operation?: GitRemoteOperation): string {
|
||||
|
||||
Reference in New Issue
Block a user