mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
* Enable pushing to configured push targets for existing fork reviews * Resolve push targets using branch.pushRemote, remote.pushDefault, and URL-valued remotes normalized to matching named remotes. * Support this push target resolution on both local and relay/SSH git handlers to prevent drift in SSH worktrees. * Offer "Push" instead of "Publish Branch" in the Source Control UI when a linked review exists and a valid push target is available. * Prevent pushing a feature branch's base branch (e.g. main) directly to a fork via remote.pushDefault. * Keep fork push target when contributor branch matches base branch name Ensure that a fork push target is not incorrectly discarded when its branch name matches the base branch name on another remote (for example, targeting fork/main while the base is origin/main). Previously, the matching logic only compared the branch leaf name, which treated different remotes as identical and disabled the push target. We now qualify the ref comparison with the remote name to differentiate them.
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
|
gitExecFileAsyncMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./runner', () => ({
|
|
gitExecFileAsync: gitExecFileAsyncMock
|
|
}))
|
|
|
|
import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from './remote'
|
|
|
|
describe('git remote operations', () => {
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
})
|
|
|
|
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('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('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('rebases from the selected remote base ref', async () => {
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'origin\nupstream\n', stderr: '' })
|
|
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
|
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
|
|
|
await gitPullRebaseFromBase('/repo', 'upstream/main')
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[['remote'], { cwd: '/repo' }],
|
|
[['check-ref-format', '--branch', 'main'], { cwd: '/repo' }],
|
|
[['pull', '--rebase', 'upstream', 'main'], { cwd: '/repo' }]
|
|
])
|
|
})
|
|
|
|
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: '', stderr: '' })
|
|
|
|
await gitPullRebaseFromBase('/repo', 'fork/team/feature/base')
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
|
|
['pull', '--rebase', 'fork/team', 'feature/base'],
|
|
{ cwd: '/repo' }
|
|
)
|
|
})
|
|
|
|
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('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.'
|
|
)
|
|
})
|
|
})
|