mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
`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.
569 lines
22 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|