Files
orca/src/main/git/remote.test.ts
T
Jinjing 7e76bb3aec Fix rebase race by fetching to private ref before rebasing (#15990)
* 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.

* Fix rebase race by fetching to private ref with timeout

Concurrent fetches can interfere with remote-tracking refs between
fetch and rebase. Use a unique private ref and 60-second timeout to
isolate each rebase operation and prevent hangs on stalled remotes.
Extract gitPullRebaseFromBase to a dedicated module.

* fix rebase race by fetching to private ref with timeouts

Concurrent fetches can replace FETCH_HEAD and remote-tracking refs between
fetch and rebase, causing the rebase to fail. Fetch to a temporary private
ref instead, use --no-write-fetch-head when available (Git 2.29+), and
serialize FETCH_HEAD access for older versions. Add process termination
barriers to ensure proper cleanup and extend timeouts for SSH operations.

* Fix rebase race by fetching to both private and tracking refs

Concurrent fetches between source and rebase can replace remote-tracking refs,
causing rebases to use stale bases. Now fetch to both a private ref and the
remote-tracking ref simultaneously, ensuring the tracking ref stays current.

Also improves process termination for WSL guests with process-group tracking,
fixes process-tree termination timeouts on POSIX, and serializes FETCH_HEAD
operations for linked worktrees through their shared Git directory.

* Add WSL setsid --wait probe and barrier termination timeout

Probe for `setsid --wait` support and fall back to unwrapped execution for BusyBox compatibility. Add a deadline for process termination barriers to prevent hanging when tree termination cannot be verified. Update tests for cross-platform compatibility.

* Add wsl-process-group-termination to WSL invocation allowlist

* Serialize per-worktree git mutations to fix rebase race

Introduce operation locking for each worktree to prevent concurrent
mutations (like rebase) from interfering with each other. Ensures
rebasing a linked worktree doesn't affect the source worktree state.
Add SIGKILL fallback if process termination barriers cannot verify
tree termination.

* Serialize pull and fastForward operations per-worktree

- Extract generic git operation lock to reuse locking pattern
- Refactor existing locks to use the generic implementation
- Apply per-worktree serialization to pull and fastForward to prevent races

* Route WSL group termination through runWslProcess

ce743a4fd0 silenced the wsl-invocation boundary guard by appending
wsl-process-group-termination.ts to the allowlist. That fixture only
grows when the scanner learns to see a spawn it was blind to, and only
shrinks for a migration -- this was new code on this branch, so the
entry was the boundary regressing rather than the guard getting honest.

Migrate the kill instead. terminate() now calls runWslProcess with the
script form (`<shell> -c <script> -- <args>`), which keeps the group id
in $1, so the payload is unchanged. The script is plain POSIX, so it
must not pin shell: 'bash'; it calls only builtins and coreutils on the
default PATH and reads no login environment, so loginPath is 'none'.

wrapGuestArgs() is untouched: its argv is spliced into git/runner.ts's
own wsl.exe invocation, which is a long-standing allowlist entry.

The unit test now mocks runWslProcess and asserts the spec shape --
distro, loginPath, the group id in args -- so a regression back to a raw
spawn fails here as well as at the boundary guard.

* Assert cleanup is defined before accessing properties
2026-08-24 12:11:55 -07:00

791 lines
30 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') {
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' }
)
})
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('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.'
)
})
})