Files
orca/src/main/git/upstream.test.ts
T
Neil 328caa2160 fix(git): reduce queries and preserve data across execution hosts (#24602)
* fix(git): reduce queries and preserve data across execution hosts

* fix(ci): exercise pinned Git and serialize mobile dependency entrypoints

* fix(relay): preserve fresh diff retries after hung shared reads

* test(git): wait for fetch barrier before canceling preparation

* fix(i18n): describe index-preserving discard in every locale

* fix(git): retain clone diagnostics and allow WSL policy startup

* test(git): refresh default-base and branch-safety fixtures
2026-10-02 18:05:37 -07:00

691 lines
25 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream'
import { runWithGitReadCacheInvalidation } from './status'
const missingTrackingRefError = new Error(
"fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" +
"Use '--' to separate paths from revisions, like this:\n" +
"'git <command> [<revision>...] -- [<file>...]'"
)
describe('getUpstreamStatus', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
invalidateGitUpstreamStatusReads()
})
// Why: the CI-enforced guard that the native/WSL path actually coalesces rather than fanning out.
it('shares one physical read across ten identical native callers', async () => {
let resolveSymbolicRef = (): void => {}
const symbolicRefGate = new Promise<void>((resolve) => {
resolveSymbolicRef = resolve
})
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
await symbolicRefGate
return { stdout: 'main\n' }
}
if (args[0] === 'rev-parse') {
return { stdout: 'origin/main\n' }
}
if (args[0] === 'rev-list') {
return { stdout: '0\t0\n' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const reads = Array.from({ length: 10 }, () => getUpstreamStatus('/repo'))
resolveSymbolicRef()
const results = await Promise.all(reads)
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')
).toHaveLength(1)
expect(new Set(results).size).toBe(1)
// A settled lease is dropped, so the next read must issue fresh Git work.
await getUpstreamStatus('/repo')
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')
).toHaveLength(2)
})
it('isolates physical reads by worktree, native or WSL host, and every target field', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'main\n' })
}
if (args[0] === 'check-ref-format') {
return Promise.resolve({ stdout: '' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.resolve({ stdout: 'origin/main\n' })
}
if (args[0] === 'rev-parse' && args.includes('--verify')) {
return Promise.resolve({ stdout: 'abc123\n' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '0\t0\n' })
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const baseTarget = { remoteName: 'fork', branchName: 'feature' }
await Promise.all([
getUpstreamStatus('/repo-a'),
getUpstreamStatus('/repo-b'),
getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Ubuntu' }),
getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Debian' }),
getUpstreamStatus('/repo-a', baseTarget),
getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }),
getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }),
getUpstreamStatus('/repo-a', {
...baseTarget,
remoteUrl: 'https://github.com/example/fork.git'
}),
getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }),
getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true })
])
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')
).toHaveLength(4)
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'check-ref-format')
).toHaveLength(6)
})
it('runs fresh physical work after a normalized rejection', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
.mockRejectedValueOnce(new Error('fatal: authentication failed'))
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
.mockResolvedValueOnce({ stdout: '0\t0\n' })
await expect(getUpstreamStatus('/repo')).rejects.toThrow('fatal: authentication failed')
await expect(getUpstreamStatus('/repo')).resolves.toMatchObject({
hasUpstream: true,
upstreamName: 'origin/main'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(6)
})
it('uses the common pre/post mutation fence for physical upstream reads', async () => {
const pendingReads: {
promise: Promise<{ stdout: string }>
resolve: (value: { stdout: string }) => void
}[] = []
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
let resolve!: (value: { stdout: string }) => void
const promise = new Promise<{ stdout: string }>((innerResolve) => {
resolve = innerResolve
})
pendingReads.push({ promise, resolve })
return promise
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: 'origin/main\n' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '0\t0\n' })
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
let finishMutation!: () => void
const mutationGate = new Promise<void>((resolve) => {
finishMutation = resolve
})
const before = getUpstreamStatus('/repo')
await vi.waitFor(() => expect(pendingReads).toHaveLength(1))
const mutation = runWithGitReadCacheInvalidation(() => mutationGate)
const during = getUpstreamStatus('/repo')
await vi.waitFor(() => expect(pendingReads).toHaveLength(2))
finishMutation()
await mutation
const after = getUpstreamStatus('/repo')
await vi.waitFor(() => expect(pendingReads).toHaveLength(3))
pendingReads.forEach(({ resolve }) => resolve({ stdout: 'main\n' }))
await Promise.all([before, during, after])
expect(pendingReads).toHaveLength(3)
})
it('returns upstream and ahead/behind counts when tracking is configured', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
.mockResolvedValueOnce({ stdout: '2\t3\n' })
.mockResolvedValueOnce({ stdout: '+ abc123 remote work\n' })
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 2,
behind: 3,
behindCommitsArePatchEquivalent: false
})
})
it('marks diverged upstream commits as patch-equivalent after a rebase', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n' })
.mockResolvedValueOnce({ stdout: '14\t3\n' })
.mockResolvedValueOnce({
stdout:
'= ac503deae Stabilize pull request creation flow\n' +
'= 7dc0fc1a6 Clean up fork PR remotes after worktree deletion\n'
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
})
})
it('keeps configured local-branch upstreams', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: '1\t0\n' })
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'main',
ahead: 1,
behind: 0
})
})
it('returns hasUpstream=false when upstream output is empty', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockResolvedValueOnce({ stdout: '\n' })
.mockResolvedValueOnce({ stdout: '' })
.mockRejectedValueOnce(new Error('missing remote branch'))
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0
})
})
it('returns hasUpstream=false when upstream is missing', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockRejectedValueOnce(new Error('fatal: no upstream configured'))
.mockResolvedValueOnce({ stdout: '' })
.mockRejectedValueOnce(new Error('missing remote branch'))
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0
})
})
it('returns hasUpstream=false when the configured tracking ref is missing', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockRejectedValueOnce(missingTrackingRefError)
.mockResolvedValueOnce({ stdout: '' })
.mockRejectedValueOnce(new Error('missing remote branch'))
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0
})
})
it('uses the same-name origin branch when a legacy worktree tracks origin/main', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
.mockResolvedValueOnce({ stdout: 'abc123\n' })
.mockResolvedValueOnce({ stdout: '3\t1\n' })
.mockResolvedValueOnce({ stdout: '+ def456 remote work\n' })
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 3,
behind: 1,
behindCommitsArePatchEquivalent: false
})
})
it('uses a named remote that matches a URL-valued branch remote', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'imp/chinese-translation\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' })
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.base')) {
return Promise.reject(new Error('missing branch base'))
}
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'origin') {
return Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n' })
}
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
if (args[0] === 'remote' && args[1] === '-v') {
return Promise.resolve({
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')
})
}
if (args[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' })
}
if (
args[0] === 'rev-parse' &&
args.includes('refs/remotes/pr-pynickle-orca/imp/chinese-translation')
) {
return Promise.resolve({ stdout: 'fork-head\n' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '2\t0\n' })
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'pr-pynickle-orca/imp/chinese-translation',
ahead: 2,
behind: 0
})
})
it('uses a fork head branch even when its name matches the base branch', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'review/pr-1\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) {
return Promise.resolve({ stdout: 'fork\n' })
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) {
return Promise.resolve({ stdout: 'refs/heads/main\n' })
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) {
return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/fork/main')) {
return Promise.resolve({ stdout: 'fork-head\n' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '3\t0\n' })
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'fork/main',
ahead: 3,
behind: 0
})
})
it('marks a URL-valued branch push target when no matching remote is configured', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'imp/chinese-translation\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
return Promise.reject(new Error('missing pushDefault'))
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' })
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.base')) {
return Promise.reject(new Error('missing branch base'))
}
if (args[0] === 'remote' && args[1] === 'get-url') {
return Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n' })
}
if (args[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/imp/chinese-translation')) {
return Promise.reject(new Error('missing origin tracking ref'))
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0,
hasConfiguredPushTarget: true
})
})
it('marks a fork head push target when the same-named base branch is on another remote', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'review/pr-1\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.pushRemote')) {
return Promise.resolve({ stdout: 'fork\n' })
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
return Promise.reject(new Error('missing pushDefault'))
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) {
return Promise.resolve({ stdout: 'fork\n' })
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) {
return Promise.resolve({ stdout: 'refs/heads/main\n' })
}
if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) {
return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/fork/main')) {
return Promise.reject(new Error('missing fork tracking ref'))
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/review/pr-1')) {
return Promise.reject(new Error('missing origin review branch'))
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0,
hasConfiguredPushTarget: true
})
})
it('does not mark origin base-branch config as a push target', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'feature\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.feature.pushRemote')) {
return Promise.reject(new Error('missing pushRemote'))
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
return Promise.reject(new Error('missing pushDefault'))
}
if (args[0] === 'config' && args.includes('branch.feature.remote')) {
return Promise.resolve({ stdout: 'origin\n' })
}
if (args[0] === 'config' && args.includes('branch.feature.merge')) {
return Promise.resolve({ stdout: 'refs/heads/main\n' })
}
if (args[0] === 'config' && args.includes('branch.feature.base')) {
return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
return Promise.reject(new Error('missing origin/main tracking ref'))
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
return Promise.reject(new Error('missing origin/feature tracking ref'))
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0
})
})
it('does not mark remote.pushDefault plus origin base branch as a push target', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'feature/fix\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.reject(new Error('fatal: no upstream configured'))
}
if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) {
return Promise.reject(new Error('missing pushRemote'))
}
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
return Promise.resolve({ stdout: 'fork\n' })
}
if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) {
return Promise.resolve({ stdout: 'origin\n' })
}
if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) {
return Promise.resolve({ stdout: 'refs/heads/main\n' })
}
if (args[0] === 'config' && args.includes('branch.feature/fix.base')) {
return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature/fix')) {
return Promise.reject(new Error('missing origin feature branch'))
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: false,
ahead: 0,
behind: 0
})
})
it('keeps a configured upstream whose remote name contains a slash', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'symbolic-ref') {
return Promise.resolve({ stdout: 'feature\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
return Promise.resolve({ stdout: 'origin/team/feature\n' })
}
if (args[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\norigin/team\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
return Promise.resolve({ stdout: 'origin-feature-oid\n' })
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/team/feature')) {
return Promise.resolve({ stdout: '2\t0\n' })
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return Promise.resolve({ stdout: '9\t9\n' })
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const result = await getUpstreamStatus('/repo')
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'origin/team/feature',
ahead: 2,
behind: 0
})
})
it('uses an explicit publish target instead of the configured upstream', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '1\t2\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '+ def456 remote work\n', stderr: '' })
const result = await getUpstreamStatus('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
expect(result).toEqual({
hasUpstream: true,
upstreamName: 'fork/feature/fix',
ahead: 1,
behind: 2,
behindCommitsArePatchEquivalent: false
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['rev-parse', '--verify', '--quiet', 'refs/remotes/fork/feature/fix'], { cwd: '/repo' }],
[
['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/fork/feature/fix'],
{ cwd: '/repo' }
],
[
[
'log',
'--no-show-signature',
'--no-color',
'--oneline',
'--cherry-mark',
'--right-only',
'HEAD...refs/remotes/fork/feature/fix',
'--'
],
{ cwd: '/repo' }
]
])
})
it('routes explicit publish-target probes through the selected WSL distro', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '0\t0\n', stderr: '' })
await expect(
getUpstreamStatus(
'/repo',
{
remoteName: 'fork',
branchName: 'feature/fix'
},
{ wslDistro: 'Ubuntu' }
)
).resolves.toEqual({
hasUpstream: true,
upstreamName: 'fork/feature/fix',
ahead: 0,
behind: 0
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo', wslDistro: 'Ubuntu' }],
[
['rev-parse', '--verify', '--quiet', 'refs/remotes/fork/feature/fix'],
{ cwd: '/repo', wslDistro: 'Ubuntu' }
],
[
['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/fork/feature/fix'],
{ cwd: '/repo', wslDistro: 'Ubuntu' }
]
])
})
it('reports no upstream when an explicit publish target has not been fetched yet', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(Object.assign(new Error('git exited with 1.'), { stderr: '' }))
await expect(
getUpstreamStatus('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
).resolves.toEqual({
hasUpstream: false,
upstreamName: 'fork/feature/fix',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: true
})
})
it('does not hide git failures while checking an explicit publish target', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }).mockRejectedValueOnce(
Object.assign(new Error('fatal: not a git repository'), {
stderr: 'fatal: not a git repository'
})
)
await expect(
getUpstreamStatus('/repo', {
remoteName: 'fork',
branchName: 'feature/fix'
})
).rejects.toThrow('fatal: not a git repository')
})
})