orca/src/shared/git-fork-sync.test.ts

219 lines
6.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
syncForkDefaultBranch,
validateGitForkSyncExpectedUpstream,
type GitForkSyncRunner
} from './git-fork-sync'
afterEach(() => {
vi.restoreAllMocks()
})
function createRunner(overrides: {
remotes?: string
upstreamUrl?: string
defaultBranchOutput?: string
originExists?: boolean
upstreamExists?: boolean
aheadBehind?: string
}): { runGit: GitForkSyncRunner; calls: string[][] } {
const calls: string[][] = []
const runGit = vi.fn(async (args: string[]) => {
calls.push(args)
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: overrides.upstreamUrl ?? 'git@github.com:stablyai/orca.git\n' }
}
if (args[0] === 'remote') {
return { stdout: overrides.remotes ?? 'origin\nupstream\n' }
}
if (args[0] === 'ls-remote') {
return {
stdout:
overrides.defaultBranchOutput ??
'ref: refs/heads/main\tHEAD\n0123456789012345678901234567890123456789\tHEAD\n'
}
}
if (args[0] === 'rev-parse') {
const ref = args[2] ?? ''
if (ref.includes('origin/main') && overrides.originExists === false) {
throw new Error('missing origin branch')
}
if (ref.includes('upstream/main') && overrides.upstreamExists === false) {
throw new Error('missing upstream branch')
}
return {
stdout: ref.includes('upstream')
? '2222222222222222222222222222222222222222\n'
: '1111111111111111111111111111111111111111\n'
}
}
if (args[0] === 'rev-list') {
return { stdout: overrides.aheadBehind ?? '0\t2\n' }
}
return { stdout: '' }
})
return { runGit, calls }
}
function flattenedCommands(calls: string[][]): string {
return calls.map((args) => args.join(' ')).join('\n')
}
describe('syncForkDefaultBranch', () => {
it('pushes the upstream default branch when the fork is only behind', async () => {
const { runGit, calls } = createRunner({ aheadBehind: '0\t3\n' })
const result = await syncForkDefaultBranch(runGit)
expect(result).toMatchObject({ status: 'synced', branchName: 'main', ahead: 0, behind: 3 })
expect(calls).toContainEqual([
'push',
'origin',
'2222222222222222222222222222222222222222:refs/heads/main'
])
expect(calls).toContainEqual([
'fetch',
'--no-tags',
'--prune',
'upstream',
'+refs/heads/main:refs/remotes/upstream/main'
])
})
it('supports default branch names with slashes', async () => {
const { runGit, calls } = createRunner({
defaultBranchOutput:
'ref: refs/heads/release/1.0\tHEAD\n0123456789012345678901234567890123456789\tHEAD\n',
aheadBehind: '0\t1\n'
})
await syncForkDefaultBranch(runGit)
expect(calls).toContainEqual([
'push',
'origin',
'2222222222222222222222222222222222222222:refs/heads/release/1.0'
])
})
it('does nothing when the fork default branch already matches upstream', async () => {
const { runGit, calls } = createRunner({ aheadBehind: '0\t0\n' })
const result = await syncForkDefaultBranch(runGit)
expect(result).toMatchObject({ status: 'up-to-date', branchName: 'main' })
expect(flattenedCommands(calls)).not.toContain('push origin')
})
it('scans newline-heavy remote and default-branch output without line-array splitting', async () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
const { runGit } = createRunner({
remotes: `${'\r\n'.repeat(10_000)}origin\r\nupstream\r\n`,
defaultBranchOutput: `${'metadata\r\n'.repeat(
10_000
)}ref: refs/heads/main\tHEAD\r\n0123456789012345678901234567890123456789\tHEAD\r\n`,
aheadBehind: '0\t0\n'
})
await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({
status: 'up-to-date',
branchName: 'main'
})
const usedLineSplit = splitSpy.mock.calls.some(
([separator]) =>
(typeof separator === 'string' && separator === '\n') ||
(separator instanceof RegExp && separator.source === '\\r?\\n')
)
expect(usedLineSplit).toBe(false)
})
it('blocks when the fork has commits that are not upstream', async () => {
const { runGit, calls } = createRunner({ aheadBehind: '2\t4\n' })
const result = await syncForkDefaultBranch(runGit)
expect(result).toMatchObject({
status: 'blocked',
reason: 'diverged',
ahead: 2,
behind: 4
})
const commands = flattenedCommands(calls)
expect(commands).not.toContain('push origin')
expect(commands).not.toContain('reset --hard')
expect(commands).not.toContain('pull')
expect(commands).not.toContain('rebase')
expect(commands).not.toContain('force')
})
it('blocks when the upstream remote is missing', async () => {
const { runGit } = createRunner({ remotes: 'origin\n' })
await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({
status: 'blocked',
reason: 'missing-upstream'
})
})
it('blocks when the upstream remote no longer matches the expected fork metadata', async () => {
const { runGit, calls } = createRunner({
upstreamUrl: 'git@github.com:someone-else/orca.git\n'
})
await expect(
syncForkDefaultBranch(runGit, {
expectedUpstream: { owner: 'stablyai', repo: 'orca' }
})
).resolves.toMatchObject({
status: 'blocked',
reason: 'upstream-mismatch'
})
expect(flattenedCommands(calls)).not.toContain('fetch')
expect(flattenedCommands(calls)).not.toContain('push')
})
it('blocks when a non-GitHub upstream remote has the expected owner and repo suffix', async () => {
const { runGit, calls } = createRunner({
upstreamUrl: 'ssh://evil.example.com/stablyai/orca.git\n'
})
await expect(
syncForkDefaultBranch(runGit, {
expectedUpstream: { owner: 'stablyai', repo: 'orca' }
})
).resolves.toMatchObject({
status: 'blocked',
reason: 'upstream-mismatch'
})
expect(flattenedCommands(calls)).not.toContain('fetch')
expect(flattenedCommands(calls)).not.toContain('push')
})
it('rejects malformed expected upstream metadata instead of disabling identity validation', async () => {
const { runGit } = createRunner({})
await expect(
syncForkDefaultBranch(runGit, {
expectedUpstream: { owner: ' ', repo: 'orca' }
})
).rejects.toThrow('Invalid expected upstream.')
})
it('rejects missing expected upstream metadata when required by a boundary', () => {
expect(() => validateGitForkSyncExpectedUpstream(undefined, { required: true })).toThrow(
'Expected upstream is required.'
)
})
it('blocks when origin lacks the upstream default branch', async () => {
const { runGit } = createRunner({ originExists: false })
await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({
status: 'blocked',
reason: 'missing-origin-branch',
branchName: 'main'
})
})
})