Files
orca/src/main/git/remote.test.ts
T
Neil 104f9655e4 perf(git): answer remote-URL questions from one subprocess, not one per remote (#18158)
Four copies of the same loop ran `git remote` and then a serial
`git remote get-url <name>` per remote to answer "which remote has this
URL". On a repo with 58 remotes that is 59 subprocesses -- measured at
1083 ms -- for one question, and worktree create asks it several times.
`git remote -v` answers for every remote from one child, reporting the
same insteadOf-expanded first fetch URL `get-url` prints.

The batched `cat-file --batch-check` branch-conflict probe decides from
stdout, but its WSL route was unfenced, so a login-shell fallback printed
the distro banner onto the stream it parses. That broke the
one-line-per-ref contract, made every batch undecided, and fell straight
back to one `show-ref` per remote -- the cost the batch exists to remove.

Measured at 58 remotes / 4346 branches, spawns and wall time:
  push-target remote scan      59 -> 1  (1083 ms -> 8 ms)
  branch-conflict probe        60 -> 3  (984 ms -> 43 ms)
  configured push target      123 -> 6  (2707 ms -> 157 ms)
2026-09-02 12:53:48 -07:00

904 lines
34 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { REBASE_SOURCE_FETCH_TIMEOUT_MS } from '../../shared/git-rebase-source'
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from './remote'
const REBASE_OPERATION_OPTIONS = {
cwd: '/repo',
terminationBarrier: true,
captureWslLoginShellOutput: true
}
describe('git remote operations', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
clearGitCapabilityStateForTests()
})
it('pushes to origin when no upstream is configured', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('no branch'), { code: 1 }))
await gitPush('/repo', true)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'origin', 'HEAD'],
{ cwd: '/repo' }
)
})
it('pushes to the configured upstream remote and branch', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'review/pr-1738\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1738.remote')) {
return { stdout: 'pr-prateek-orca\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1738.pushRemote')) {
return { stdout: 'pr-prateek-orca\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1738.merge')) {
return { stdout: 'refs/heads/prateek/fix-sidebar-agents-toggle\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1738.base')) {
throw new Error('missing branch base')
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['config', '--get', 'branch.review/pr-1738.remote'],
{ cwd: '/repo' }
)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'pr-prateek-orca', 'HEAD:prateek/fix-sidebar-agents-toggle'],
{ cwd: '/repo' }
)
})
it('does not combine remote.pushDefault with a base-branch merge target', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature/fix\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) {
throw new Error('missing pushRemote')
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
return { stdout: 'fork\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) {
return { stdout: 'refs/heads/main\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.feature/fix.base')) {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
['push', '--set-upstream', 'fork', 'HEAD:main'],
{ cwd: '/repo' }
)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'origin', 'HEAD'],
{ cwd: '/repo' }
)
})
it('keeps a fork head target when the contributor branch matches the base branch name', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'review/pr-1\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) {
return { stdout: 'fork\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.pushRemote')) {
return { stdout: 'fork\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) {
return { stdout: 'refs/heads/main\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'fork', 'HEAD:main'],
{ cwd: '/repo' }
)
})
it('pushes to a URL-valued branch pushRemote when no named remote exists', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
throw new Error('missing pushDefault')
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
[
'push',
'--set-upstream',
'https://github.com/pynickle/orca.git',
'HEAD:imp/chinese-translation'
],
{ cwd: '/repo' }
)
})
it('normalizes a URL-valued branch remote to a matching named remote before pushing', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) {
throw new Error('missing pushRemote')
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
throw new Error('missing pushDefault')
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'origin') {
return { stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: [
'origin\thttps://github.com/stablyai/orca.git (fetch)',
'origin\thttps://github.com/stablyai/orca.git (push)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
].join('\n'),
stderr: ''
}
}
if (args[0] === 'remote') {
return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'],
{ cwd: '/repo' }
)
})
// Regression: normalizing a URL-valued push remote used to run `git remote` and then a
// serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo.
it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => {
const remotes = [
{ name: 'origin', url: 'https://github.com/stablyai/orca.git' },
...Array.from({ length: 56 }, (_, index) => ({
name: `pr-user${index}-orca`,
url: `https://github.com/user${index}/orca.git`
})),
{ name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' }
]
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config') {
throw new Error(`config key is not set: ${args.join(' ')}`)
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: remotes
.flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
.join('\n'),
stderr: ''
}
}
if (args[0] === 'remote') {
throw new Error(`unexpected remote scan: ${args.join(' ')}`)
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote')
expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']])
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'],
{ cwd: '/repo' }
)
})
it('uses an explicit push target even when it differs from the local branch name', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPush('/repo', false, {
remoteName: 'origin',
branchName: 'contributor/fix-sidebar'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['push', '--set-upstream', 'origin', 'HEAD:contributor/fix-sidebar'],
{ cwd: '/repo' }
)
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'contributor/fix-sidebar'], { cwd: '/repo' }],
[['push', '--set-upstream', 'origin', 'HEAD:contributor/fix-sidebar'], { cwd: '/repo' }]
])
})
it('passes --force-with-lease when requested', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'refs/heads/feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPush('/repo', false, undefined, { forceWithLease: true })
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--force-with-lease', '--set-upstream', 'origin', 'HEAD:feature'],
{ cwd: '/repo' }
)
})
it('maps non-fast-forward push failures to an actionable message', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(new Error('remote rejected: non-fast-forward'))
await expect(gitPush('/repo', false)).rejects.toThrow(
'Push rejected: remote has newer commits (non-fast-forward). Please pull or sync first.'
)
})
it('maps recursive submodule push failures to submodule-specific guidance', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
"Command failed: git push\nPushing submodule 'find-cmux-followers'\n" +
' ! [rejected] master -> master (fetch first)\n' +
"Unable to push submodule 'find-cmux-followers'\n" +
'fatal: failed to push all needed submodules'
)
)
await expect(gitPush('/repo', false)).rejects.toThrow(
"Submodule 'find-cmux-followers' has remote changes. Pull inside the submodule, then try again."
)
})
it('passes through clean tail line when push error does not match known patterns', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error('Command failed: git push\nfatal: something obscure happened')
)
await expect(gitPush('/repo', false)).rejects.toThrow('fatal: something obscure happened')
})
it('preserves redacted pre-push hook output from failed pushes', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
[
'Command failed: git push https://x-access-token:ghp_secret@github.com/acme/repo.git HEAD',
'husky - pre-push hook failed',
'eslint found 2 errors',
"error: failed to push some refs to 'https://ghp_tailSecret@github.com/acme/repo.git'"
].join('\n')
)
)
let caught: Error | undefined
try {
await gitPush('/repo', false)
} catch (error) {
caught = error as Error
}
expect(caught).toBeInstanceOf(Error)
expect(caught?.message).toContain('husky - pre-push hook failed')
expect(caught?.message).toContain('eslint found 2 errors')
expect(caught?.message).not.toContain('x-access-token')
expect(caught?.message).not.toContain('ghp_secret')
expect(caught?.message).not.toContain('ghp_tailSecret')
})
it('strips embedded credentials from push error messages', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
'Command failed: git push\nhttps://x-access-token:ghp_abc@github.com/foo/bar.git\nfatal: remote error'
)
)
let caught: Error | undefined
try {
await gitPush('/repo', false)
} catch (error) {
caught = error as Error
}
expect(caught).toBeInstanceOf(Error)
expect(caught?.message).not.toContain('ghp_abc')
expect(caught?.message).not.toContain('x-access-token')
})
it('strips token-only credentials (https://TOKEN@host) from push error messages', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
'Command failed: git push\nhttps://ghp_onlyToken@github.com/foo/bar.git\nfatal: remote error'
)
)
let caught: Error | undefined
try {
await gitPush('/repo', false)
} catch (error) {
caught = error as Error
}
expect(caught).toBeInstanceOf(Error)
expect(caught?.message).not.toContain('ghp_onlyToken')
})
it('falls back to a generic message for non-Error rejections', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce('string')
await expect(gitPush('/repo', false)).rejects.toThrow('Git remote operation failed.')
})
it("runs pull with the user's configured strategy", async () => {
gitExecFileAsyncMock
.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' }]
])
})
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('retries a divergent pushTarget 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: validate the target, then the plain pull rejects.
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(divergentError)
// Fallback attempt: re-validate, then pull --no-rebase (merge) succeeds.
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPull('/repo', { remoteName: 'fork', branchName: 'feature/fix' })
// The merge flag is spliced ahead of the positional remote/branch args.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', 'fork', 'feature/fix'], { cwd: '/repo' }],
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', '--no-rebase', 'fork', 'feature/fix'], { cwd: '/repo' }]
])
})
it('surfaces a normalized error and does not loop when the merge fallback itself fails', async () => {
const divergentError = new Error(
'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.'
)
const mergeConflictError = new Error(
'Command failed: git pull --no-rebase\nCONFLICT (content): Merge conflict in file.txt'
)
gitExecFileAsyncMock
// First attempt fails with the reconciliation error.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(divergentError)
// The single merge fallback then fails on a real conflict.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(mergeConflictError)
await expect(gitPull('/repo')).rejects.toThrow()
// At-most-once retry: probe+pull, then probe+fallback-pull — no further attempts.
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6)
})
it('pulls the same-name origin branch for legacy base-tracking worktrees', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/main\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'abc123\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' }],
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/feature'], { cwd: '/repo' }],
[['pull', 'origin', 'feature'], { cwd: '/repo' }]
])
})
it('pulls from the explicit publish target when one is provided', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPull('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', 'fork', 'feature/fix'], { cwd: '/repo' }]
])
})
it('fast-forwards with --ff-only using the configured upstream', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitFastForward('/repo')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }],
[['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }],
[['pull', '--ff-only'], { cwd: '/repo' }]
])
})
it('fast-forwards from the explicit publish target when one is provided', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitFastForward('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', '--ff-only', 'fork', 'feature/fix'], { cwd: '/repo' }]
])
})
it('fetches to a private ref then rebases from the selected remote base ref', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'origin\nupstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPullRebaseFromBase('/repo', 'upstream/main')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['remote'], REBASE_OPERATION_OPTIONS],
[['check-ref-format', '--branch', 'main'], REBASE_OPERATION_OPTIONS],
[
['merge-base', '--fork-point', 'refs/remotes/upstream/main', 'HEAD'],
REBASE_OPERATION_OPTIONS
],
[
[
'fetch',
'--no-write-fetch-head',
'upstream',
expect.stringMatching(/^\+refs\/heads\/main:refs\/orca\/rebase\//),
'+refs/heads/main:refs/remotes/upstream/main'
],
{ ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS }
],
[
['rebase', '--onto', expect.stringMatching(/^refs\/orca\/rebase\//), 'fork-point'],
REBASE_OPERATION_OPTIONS
],
[['update-ref', '-d', expect.stringMatching(/^refs\/orca\/rebase\//)], { cwd: '/repo' }]
])
const fetchRefspec = gitExecFileAsyncMock.mock.calls[3][0][3]
const rebasedRef = gitExecFileAsyncMock.mock.calls[4][0][2]
const deletedRef = gitExecFileAsyncMock.mock.calls[5][0][2]
expect(fetchRefspec).toBe(`+refs/heads/main:${rebasedRef}`)
expect(gitExecFileAsyncMock.mock.calls[3][0][4]).toBe(
'+refs/heads/main:refs/remotes/upstream/main'
)
expect(deletedRef).toBe(rebasedRef)
})
it('uses the longest configured remote name when rebasing from a base ref', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'fork\nfork/team\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPullRebaseFromBase('/repo', 'fork/team/feature/base')
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
5,
['rebase', '--onto', expect.stringMatching(/^refs\/orca\/rebase\//), 'fork-point'],
REBASE_OPERATION_OPTIONS
)
})
it('rebases when the selected remote has not been fetched before', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('missing remote-tracking ref'))
.mockResolvedValueOnce({ stdout: 'head\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(gitPullRebaseFromBase('/repo', 'upstream/main')).resolves.toBeUndefined()
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
5,
[
'fetch',
'--no-write-fetch-head',
'upstream',
expect.stringMatching(/^\+refs\/heads\/main:refs\/orca\/rebase\//),
'+refs/heads/main:refs/remotes/upstream/main'
],
{ ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS }
)
})
it('fast-forwards an unborn branch from the fetched private ref', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('unborn HEAD'))
.mockRejectedValueOnce(new Error('unborn HEAD'))
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPullRebaseFromBase('/repo', 'upstream/main')
const fetchedRef = gitExecFileAsyncMock.mock.calls[4][0][3]
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
6,
['merge', '--ff-only', fetchedRef.slice(fetchedRef.indexOf(':') + 1)],
REBASE_OPERATION_OPTIONS
)
})
it('removes the private ref when rebase fails', async () => {
const controller = new AbortController()
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockImplementationOnce(async () => {
controller.abort()
throw new Error('fatal: rebase conflict')
})
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(
gitPullRebaseFromBase('/repo', 'upstream/main', { signal: controller.signal })
).rejects.toThrow('fatal: rebase conflict')
const rebasedRef = gitExecFileAsyncMock.mock.calls[4][0][2]
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(6, ['update-ref', '-d', rebasedRef], {
cwd: '/repo'
})
})
it('serializes the private fetch when Git cannot avoid writing FETCH_HEAD', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' })
.mockRejectedValueOnce(new Error("error: unknown option `no-write-fetch-head'"))
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPullRebaseFromBase('/repo', 'upstream/main')
const preferredRefspecs = gitExecFileAsyncMock.mock.calls[3][0].slice(3)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
5,
['fetch', 'upstream', ...preferredRefspecs],
{ ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS }
)
})
it('normalizes pull authentication errors to a friendly message', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(new Error('Authentication failed'))
await expect(gitPull('/repo')).rejects.toThrow(
'Authentication failed. Check your remote credentials.'
)
})
it('normalizes pull dirty-worktree aborts to a friendly message', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(
new Error(
'Command failed: git pull\n' +
'error: Your local changes to the following files would be overwritten by merge:\n' +
'\tsrc/app.ts\n' +
'Please commit your changes or stash them before you merge.\n' +
'Aborting'
)
)
await expect(gitPull('/repo')).rejects.toThrow(
'Pull would overwrite local changes. Commit, stash, or discard them before pulling.'
)
})
it('normalizes pull untracked-file aborts to a friendly message', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(
new Error(
'Command failed: git pull\n' +
'error: The following untracked working tree files would be overwritten by merge:\n' +
'\tsrc/new.ts\n' +
'Please move or remove them before you merge.\n' +
'Aborting'
)
)
await expect(gitPull('/repo')).rejects.toThrow(
'Pull would overwrite untracked files. Move, remove, or add them before pulling.'
)
})
it('runs fetch with prune', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await gitFetch('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', '--prune'], { cwd: '/repo' })
})
it('passes the selected WSL distro through fetch validation and execution', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitFetch(
'/repo',
{
remoteName: 'fork',
branchName: 'feature/fix'
},
{ wslDistro: 'Ubuntu' }
)
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo', wslDistro: 'Ubuntu' }],
[['fetch', '--prune', 'fork'], { cwd: '/repo', wslDistro: 'Ubuntu' }]
])
})
it('fetches the explicit publish target remote when provided', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitFetch('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['fetch', '--prune', 'fork'], { cwd: '/repo' }]
])
})
it('fetches explicit publish target remotes whose names contain slashes', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitFetch('/repo', {
remoteName: 'foo/bar',
branchName: 'feature/fix'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['fetch', '--prune', 'foo/bar'], { cwd: '/repo' }]
])
})
it('drops a stale branch-specific refspec and retries when the fork branch was deleted upstream (#17828)', async () => {
let fetchAttempts = 0
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'fetch') {
fetchAttempts += 1
if (fetchAttempts === 1) {
throw Object.assign(new Error("fatal: couldn't find remote ref refs/heads/gone"), {
stderr: "fatal: couldn't find remote ref refs/heads/gone\n"
})
}
return { stdout: '', stderr: '' }
}
if (args[0] === 'config' && args[1] === '--get-all') {
return {
stdout:
'+refs/heads/gone:refs/remotes/fork/gone\n+refs/heads/keep:refs/remotes/fork/keep\n',
stderr: ''
}
}
return { stdout: '', stderr: '' }
})
await gitFetch('/repo', { remoteName: 'fork', branchName: 'keep' })
expect(fetchAttempts).toBe(2)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['config', '--unset-all', 'remote.fork.fetch'],
{ cwd: '/repo' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['config', '--add', 'remote.fork.fetch', '+refs/heads/keep:refs/remotes/fork/keep'],
{ cwd: '/repo' }
)
})
it('surfaces the original fetch error when it is not a stale-refspec failure', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unreachable')
}
return { stdout: '', stderr: '' }
})
await expect(gitFetch('/repo', { remoteName: 'fork', branchName: 'keep' })).rejects.toThrow(
'network unreachable'
)
})
it('normalizes fetch authentication errors to a friendly message', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed'))
await expect(gitFetch('/repo')).rejects.toThrow(
'Authentication failed. Check your remote credentials.'
)
})
})