Files
orca/src/relay/git-handler-remote-sync.test.ts
T
Jinjing 8dd5afb996 Fix rebase race by fetching to private ref before rebasing
`git pull --rebase` is vulnerable to concurrent fetches modifying remote-tracking refs during execution. Fetch to a temporary private ref (refs/orca/rebase/*) first, then rebase from that stable ref to avoid the race condition.
2026-08-24 11:35:13 -07:00

569 lines
22 KiB
TypeScript

/**
* GitHandler remote synchronization RPCs: upstream divergence, fetch, fork
* sync, fast-forward, and the narrow review-head fetches for GitHub pull
* requests and GitLab merge requests.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import { reviewHeadRemoteRefComponent } from '../shared/review-head-tracking-ref'
import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup'
import {
createGitHandlerRelay,
createGitTempDir,
removeGitTempDir
} from './git-handler-test-harness'
describe('GitHandler', () => {
let dispatcher: MockDispatcher
let tmpDir: string
beforeEach(() => {
tmpDir = createGitTempDir()
;({ dispatcher } = createGitHandlerRelay())
})
afterEach(async () => {
await removeGitTempDir(tmpDir)
})
describe('remote operations', () => {
it('returns upstream divergence for tracked branches', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const result = (await dispatcher.callRequest('git.upstreamStatus', {
worktreePath: tmpDir
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
expect(result.hasUpstream).toBe(false)
expect(result.ahead).toBe(0)
expect(result.behind).toBe(0)
})
it('reports ahead/behind counts against a real upstream remote', async () => {
// Why: exercise the configured-upstream happy path (rev-parse HEAD@{u} + rev-list --left-right) the no-upstream test misses.
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
// Juggle local commits/resets to produce specific ahead/behind counts vs. upstream.
writeFileSync(path.join(tmpDir, 'ahead1.txt'), 'a1')
gitCommit(tmpDir, 'ahead1')
writeFileSync(path.join(tmpDir, 'ahead2.txt'), 'a2')
gitCommit(tmpDir, 'ahead2')
// Push so remote is at ahead2 (so after we reset below, we are behind).
execFileSync('git', ['push', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' })
// Reset local back to the first commit: 0 ahead, 2 behind.
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
const result = (await dispatcher.callRequest('git.upstreamStatus', {
worktreePath: tmpDir
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
expect(result.hasUpstream).toBe(true)
expect(result.upstreamName).toBe(`origin/${branch}`)
expect(result.ahead).toBe(0)
expect(result.behind).toBe(2)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('reports ahead/behind counts against a configured local-branch upstream', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['branch', '--set-upstream-to', baseRef], {
cwd: tmpDir,
stdio: 'pipe'
})
writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature')
gitCommit(tmpDir, 'feature commit')
const result = (await dispatcher.callRequest('git.upstreamStatus', {
worktreePath: tmpDir
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
expect(result.hasUpstream).toBe(true)
expect(result.upstreamName).toBe(baseRef)
expect(result.ahead).toBe(1)
expect(result.behind).toBe(0)
})
it('fetches from a configured remote without throwing', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
await expect(
dispatcher.callRequest('git.fetch', { worktreePath: tmpDir })
).resolves.not.toThrow()
// FETCH_HEAD exists only after a successful fetch, confirming the remote was actually contacted.
await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined()
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('rebases from the original fork point after a remote force-push', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-bare-'))
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-producer-'))
const producerDir = path.join(producerParent, 'repo')
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'base')
const branch = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
const forkPoint = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
execFileSync('git', ['checkout', branch], { cwd: producerDir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
cwd: producerDir,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' })
writeFileSync(path.join(producerDir, 'discarded.txt'), 'discarded')
gitCommit(producerDir, 'discarded remote commit')
execFileSync('git', ['push'], { cwd: producerDir, stdio: 'pipe' })
execFileSync('git', ['fetch', 'origin'], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['checkout', '-b', 'feature', `origin/${branch}`], {
cwd: tmpDir,
stdio: 'pipe'
})
writeFileSync(path.join(tmpDir, 'topic.txt'), 'topic')
gitCommit(tmpDir, 'topic commit')
execFileSync('git', ['reset', '--hard', forkPoint], { cwd: producerDir, stdio: 'pipe' })
writeFileSync(path.join(producerDir, 'replacement.txt'), 'replacement')
gitCommit(producerDir, 'replacement remote commit')
execFileSync('git', ['push', '--force', 'origin', branch], { cwd: producerDir, stdio: 'pipe' })
await dispatcher.callRequest('git.rebaseFromBase', {
worktreePath: tmpDir,
baseRef: `origin/${branch}`
})
await expect(fs.access(path.join(tmpDir, 'replacement.txt'))).resolves.toBeUndefined()
await expect(fs.access(path.join(tmpDir, 'topic.txt'))).resolves.toBeUndefined()
await expect(fs.access(path.join(tmpDir, 'discarded.txt'))).rejects.toThrow()
expect(
execFileSync('git', ['for-each-ref', '--format=%(refname)', 'refs/orca/rebase'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
).toBe('')
} finally {
await Promise.all([
fs.rm(bareDir, { recursive: true, force: true }),
fs.rm(producerParent, { recursive: true, force: true })
])
}
}, 15_000)
it('fetches the explicit publish target remote', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-fork-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['remote', 'add', 'fork', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', 'fork', 'HEAD:feature/fix'], {
cwd: tmpDir,
stdio: 'pipe'
})
await expect(
dispatcher.callRequest('git.fetch', {
worktreePath: tmpDir,
pushTarget: { remoteName: 'fork', branchName: 'feature/fix' }
})
).resolves.not.toThrow()
await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined()
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('fast-forwards the tracked branch with ff-only pull semantics', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-'))
const producerDir = path.join(producerParent, 'repo')
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
cwd: producerDir,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'Test'], {
cwd: producerDir,
stdio: 'pipe'
})
writeFileSync(path.join(producerDir, 'remote.txt'), 'remote')
gitCommit(producerDir, 'remote commit')
execFileSync('git', ['push', 'origin', branch], {
cwd: producerDir,
stdio: 'pipe'
})
await dispatcher.callRequest('git.fastForward', { worktreePath: tmpDir })
await expect(fs.readFile(path.join(tmpDir, 'remote.txt'), 'utf-8')).resolves.toBe('remote')
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
await fs.rm(producerParent, { recursive: true, force: true })
}
})
it('rejects malformed fork sync expected upstream metadata', async () => {
await expect(
dispatcher.callRequest('git.forkSync', {
worktreePath: tmpDir,
expectedUpstream: { owner: ' ', repo: 'orca' }
})
).rejects.toThrow('Invalid expected upstream.')
})
it('rejects fork sync requests without expected upstream metadata', async () => {
await expect(
dispatcher.callRequest('git.forkSync', {
worktreePath: tmpDir
})
).rejects.toThrow('Expected upstream is required.')
})
it('aborts fork sync when the relay request is canceled', async () => {
gitInit(tmpDir)
const controller = new AbortController()
controller.abort()
await expect(
dispatcher.callRequest(
'git.forkSync',
{ worktreePath: tmpDir, expectedUpstream: { owner: 'stablyai', repo: 'orca' } },
{ isStale: () => false, signal: controller.signal }
)
).rejects.toThrow(/abort/i)
})
it('refreshes one remote-tracking ref from a configured remote', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-'))
const producerDir = path.join(producerParent, 'repo')
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
cwd: producerDir,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'Test'], {
cwd: producerDir,
stdio: 'pipe'
})
writeFileSync(path.join(producerDir, 'base.txt'), 'updated')
gitCommit(producerDir, 'remote update')
execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' })
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: producerDir,
encoding: 'utf-8'
}).trim()
await dispatcher.callRequest('git.fetchRemoteTrackingRef', {
worktreePath: tmpDir,
remote: 'origin',
branch,
ref: `refs/remotes/origin/${branch}`
})
const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
await fs.rm(producerParent, { recursive: true, force: true })
}
})
it('rejects remote-tracking refreshes that target a different ref', async () => {
gitInit(tmpDir)
execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' })
await expect(
dispatcher.callRequest('git.fetchRemoteTrackingRef', {
worktreePath: tmpDir,
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/other'
})
).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.')
})
it('fetches GitHub pull request heads through the narrow fetch RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-github-pr-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'pr.txt'), 'head')
gitCommit(tmpDir, 'pr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/pull/42/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
const result = (await dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
prNumber: 42
})) as { localRef: string }
// The ref is scoped by remote identity so soft-keep can never serve
// another project's PR #42 out of the same object database.
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/pull/${component}/42`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('rejects invalid GitHub pull request head fetch requests', async () => {
await expect(
dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: '-origin',
prNumber: 42
})
).rejects.toThrow('GitHub pull request fetch remote must not start with "-".')
await expect(
dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
prNumber: 0
})
).rejects.toThrow('Invalid GitHub pull request fetch request.')
})
it('fetches GitLab merge request heads through the narrow fetch RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'mr.txt'), 'head')
gitCommit(tmpDir, 'mr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/42/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 42
})) as { localRef: string }
// The head is fetched into a dedicated ref (not shared FETCH_HEAD) so a
// concurrent fetch can't retarget the caller's rev-parse of the checkout.
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/42`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
// Legacy contract: pre-durable-ref desktop clients call this method name
// and then resolve FETCH_HEAD, which a refspec fetch still writes.
const fetchHead = execFileSync('git', ['rev-parse', '--verify', 'FETCH_HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(fetchHead).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('fetches GitLab merge request heads through the versioned durable-ref RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-ref-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'mr.txt'), 'head')
gitCommit(tmpDir, 'mr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/77/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
// Why: new clients call the versioned name; old relays 404 it and prompt reconnect.
const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHeadRef', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 77
})) as { localRef: string }
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/77`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('rejects invalid GitLab merge request head fetch requests', async () => {
await expect(
dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: '-origin',
mrIid: 42
})
).rejects.toThrow('GitLab merge request fetch remote must not start with "-".')
await expect(
dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 0
})
).rejects.toThrow('Invalid GitLab merge request fetch request.')
})
it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => {
// Why: the catch only swallows "no upstream"; other errors must surface so auth/corruption failures aren't masked.
const nonRepoDir = path.join(tmpDir, 'not-a-repo')
await fs.mkdir(nonRepoDir, { recursive: true })
await expect(
dispatcher.callRequest('git.upstreamStatus', { worktreePath: nonRepoDir })
).rejects.toThrow(/not a git repository/i)
})
})
})