Files
orca/src/relay/git-handler-pull-reconciliation.test.ts
T
Jinjing 527c692b71 Improve git pull on remote (#8524)
* 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.
2026-07-13 12:42:55 -07:00

144 lines
5.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
import { RelayContext } from './context'
import { GitHandler } from './git-handler'
import {
createMockDispatcher,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
describe('GitHandler pull reconciliation', () => {
let dispatcher: MockDispatcher
let tmpDir: string
let gitEnv: NodeJS.ProcessEnv
let previousGitConfigGlobal: string | undefined
let previousGitConfigNosystem: string | undefined
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-pull-reconciliation-'))
const globalGitConfigPath = path.join(tmpDir, 'global-gitconfig')
writeFileSync(globalGitConfigPath, '')
previousGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL
previousGitConfigNosystem = process.env.GIT_CONFIG_NOSYSTEM
process.env.GIT_CONFIG_GLOBAL = globalGitConfigPath
process.env.GIT_CONFIG_NOSYSTEM = '1'
gitEnv = {
...process.env,
GIT_CONFIG_GLOBAL: globalGitConfigPath,
GIT_CONFIG_NOSYSTEM: '1'
}
dispatcher = createMockDispatcher()
const ctx = new RelayContext()
new GitHandler(dispatcher as unknown as RelayDispatcher, ctx)
})
afterEach(async () => {
restoreGitEnv('GIT_CONFIG_GLOBAL', previousGitConfigGlobal)
restoreGitEnv('GIT_CONFIG_NOSYSTEM', previousGitConfigNosystem)
await fs.rm(tmpDir, { recursive: true, force: true })
})
function execGit(cwd: string, args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: gitEnv,
stdio: 'pipe'
})
}
function configureIdentity(cwd: string): void {
execGit(cwd, ['config', 'user.email', 'test@test.com'])
execGit(cwd, ['config', 'user.name', 'Test'])
}
function commitAll(cwd: string, message: string): void {
execGit(cwd, ['add', '.'])
execGit(cwd, ['commit', '-m', message])
}
function createDivergentFixture(): string {
const bareDir = path.join(tmpDir, 'origin.git')
const consumerDir = path.join(tmpDir, 'consumer')
const producerDir = path.join(tmpDir, 'producer')
execGit(tmpDir, ['init', '--bare', bareDir])
execGit(tmpDir, ['clone', bareDir, consumerDir])
configureIdentity(consumerDir)
writeFileSync(path.join(consumerDir, 'base.txt'), 'base\n')
commitAll(consumerDir, 'initial')
execGit(consumerDir, ['push', '--set-upstream', 'origin', 'HEAD'])
execGit(tmpDir, ['clone', bareDir, producerDir])
configureIdentity(producerDir)
writeFileSync(path.join(producerDir, 'remote.txt'), 'remote\n')
commitAll(producerDir, 'remote')
execGit(producerDir, ['push'])
writeFileSync(path.join(consumerDir, 'local.txt'), 'local\n')
commitAll(consumerDir, 'local')
return consumerDir
}
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 })
).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)
it('preserves configured rebase pull semantics', async () => {
const consumerDir = createDivergentFixture()
execGit(consumerDir, ['config', 'pull.rebase', 'true'])
await expect(
dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
).resolves.not.toThrow()
const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
expect(parentRefs).toHaveLength(1)
expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true)
expect(execGit(consumerDir, ['status', '--short'])).toBe('')
}, 15_000)
})
function restoreGitEnv(
name: 'GIT_CONFIG_GLOBAL' | 'GIT_CONFIG_NOSYSTEM',
value: string | undefined
): void {
if (value === undefined) {
delete process.env[name]
return
}
process.env[name] = value
}