Fix worktree base ref ambiguity

Resolve ambiguous git worktree base refs by qualifying local and remote branch refs before invoking git worktree add.
This commit is contained in:
Anwesh 2026-05-21 13:16:31 +05:30 committed by GitHub
parent 7cf154592c
commit 6e949d6ec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 349 additions and 21 deletions

View File

@ -0,0 +1,15 @@
import { gitExecFileAsync } from './runner'
export async function hasWorktreeBaseCommitRef(
repoPath: string,
qualifiedRef: string
): Promise<boolean> {
try {
await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], {
cwd: repoPath
})
return true
} catch {
return false
}
}

View File

@ -200,6 +200,10 @@ bare
})
describe('addWorktree', () => {
const resolveRemoteBase = () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
}
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
@ -207,6 +211,7 @@ describe('addWorktree', () => {
})
it('creates the worktree without touching the local base ref by default', async () => {
resolveRemoteBase()
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -214,8 +219,17 @@ describe('addWorktree', () => {
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
@ -224,6 +238,7 @@ describe('addWorktree', () => {
})
it('warns but does not throw when push.autoSetupRemote config fails', async () => {
resolveRemoteBase()
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected)
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails
@ -245,6 +260,7 @@ describe('addWorktree', () => {
// is a real read failure (parse error, locked file). We must NOT fall
// through to `--local set true`, which would silently overwrite whatever
// value the user actually has.
resolveRemoteBase()
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@ -259,8 +275,17 @@ describe('addWorktree', () => {
)
// No --local set was attempted: only worktree add + the failing --get.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
@ -269,6 +294,7 @@ describe('addWorktree', () => {
})
it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
resolveRemoteBase()
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value
@ -276,8 +302,17 @@ describe('addWorktree', () => {
// No --local set: --get succeeded so we preserve the user's value.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
@ -288,25 +323,36 @@ describe('addWorktree', () => {
// Why: `git config --get key` exits 0 if the key has any value at any
// scope, including the unusual case of an explicitly empty string. We
// must not fall through to `--local set true` and overwrite that.
resolveRemoteBase()
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --get succeeds with empty value
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
])
})
it('does not probe or write config when worktree add itself fails', async () => {
it('does not write config when worktree add itself fails', async () => {
// Why: a refactor that moves the config block earlier could try to write
// push.autoSetupRemote against a worktree directory that was never
// created. Pin the current ordering invariant: config calls happen only
// after worktree add succeeds.
resolveRemoteBase()
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('worktree add failed'))
await expect(
@ -314,8 +360,17 @@ describe('addWorktree', () => {
).rejects.toThrow('worktree add failed')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
]
])
@ -329,6 +384,7 @@ describe('addWorktree', () => {
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -340,8 +396,17 @@ describe('addWorktree', () => {
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
[['reset', '--hard', 'origin/main'], { cwd: '/repo' }],
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
@ -357,6 +422,7 @@ describe('addWorktree', () => {
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -379,6 +445,7 @@ describe('addWorktree', () => {
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // update-ref
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -397,6 +464,7 @@ describe('addWorktree', () => {
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -404,22 +472,28 @@ describe('addWorktree', () => {
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
// No reset --hard or update-ref — just merge-base, worktree list, status, worktree add, config --get, config --local set
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6)
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(7)
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([
'rev-parse',
'--verify',
'--quiet',
'refs/remotes/origin/main^{commit}'
])
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'origin/main'
'refs/remotes/origin/main'
])
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([
'config',
'--get',
'push.autoSetupRemote'
])
expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[6]?.[0]).toEqual([
'config',
'--local',
'push.autoSetupRemote',
@ -429,6 +503,7 @@ describe('addWorktree', () => {
it('skips updating the local branch when it has diverged', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
@ -441,7 +516,19 @@ describe('addWorktree', () => {
expect.objectContaining({ cwd: '/repo' })
],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
expect.objectContaining({ cwd: '/repo' })
],
[
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
expect.objectContaining({ cwd: '/repo' })
],
[
@ -455,6 +542,61 @@ describe('addWorktree', () => {
])
})
it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => {
// Why: repos that fetch with --tags can end up with a local tag named 'main',
// making `git worktree add ... main` fail with "fatal: Ambiguous object name".
// Qualifying as refs/heads/main tells git exactly which object to use.
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/disambig', 'main')
expect(gitExecFileAsyncMock.mock.calls[0]).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
{ cwd: '/repo' }
])
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
[
'worktree',
'add',
'--no-track',
'-b',
'feature/disambig',
'/repo-feature',
'refs/heads/main'
],
{ cwd: '/repo' }
])
})
it('qualifies slash-containing local branch names when no remote ref matches', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/release', 'release/main')
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'],
['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/release',
'/repo-feature',
'refs/heads/release/main'
],
['config', '--get', 'push.autoSetupRemote'],
['config', '--local', 'push.autoSetupRemote', 'true']
])
})
it('uses the remote name from the base ref instead of hardcoding origin', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
@ -462,6 +604,7 @@ describe('addWorktree', () => {
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote

View File

@ -1,8 +1,10 @@
import { stat } from 'fs/promises'
import { join, posix, win32 } from 'path'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import type { GitWorktreeInfo } from '../../shared/types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
type SparseWorktreeCreateError = Error & {
cleanupFailed?: boolean
@ -234,7 +236,10 @@ export async function addWorktree(
// below for the terminal ergonomics.
args.push('--no-track', '-b', branch, worktreePath)
if (baseBranch) {
args.push(baseBranch)
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef)
)
args.push(effectiveBase)
}
await gitExecFileAsync(args, { cwd: repoPath })

View File

@ -5,6 +5,7 @@
* the oxlint max-lines (300) limit.
*/
import * as path from 'path'
import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref'
import type { GitExec } from './git-handler-ops'
import { parseWorktreeList } from './git-handler-utils'
@ -30,9 +31,20 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
// (state machine, common-dir scope, old-git fallback) in the comments
// around src/main/git/worktree.ts addWorktree — those invariants apply
// identically here.
const effectiveBase = base
? await resolveWorktreeAddBaseRef(base, async (qualifiedRef) => {
try {
await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], repoPath)
return true
} catch {
return false
}
})
: undefined
const args = ['worktree', 'add', '--no-track', '-b', branchName, targetDir]
if (base) {
args.push(base)
if (effectiveBase) {
args.push(effectiveBase)
}
await git(args, repoPath)

View File

@ -684,6 +684,7 @@ describe('GitHandler', () => {
it('passes --no-track and writes push.autoSetupRemote when unset', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/remotes/origin/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
@ -696,18 +697,87 @@ describe('GitHandler', () => {
})
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['worktree', 'add', '--no-track', '-b', 'feature/test', '/relay/wt', 'origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/relay/wt',
'refs/remotes/origin/main'
],
['config', '--get', 'push.autoSetupRemote'],
['config', '--local', 'push.autoSetupRemote', 'true']
])
// cwd for worktree add is repoPath; cwd for config calls is targetDir.
expect(gitMock.mock.calls[0]?.[1]).toBe('/relay/repo')
expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/wt')
expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/repo')
expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt')
expect(gitMock.mock.calls[3]?.[1]).toBe('/relay/wt')
})
it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => {
// Why: repos that fetch with --tags can end up with a local tag named
// 'main', making `git worktree add ... main` fail with "fatal: Ambiguous
// object name". Qualifying as refs/heads/main tells git exactly which
// object to use.
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
await localDispatcher.callRequest('git.addWorktree', {
repoPath: '/relay/repo',
branchName: 'feature/disambig',
targetDir: '/relay/wt',
base: 'main'
})
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['worktree', 'add', '--no-track', '-b', 'feature/disambig', '/relay/wt', 'refs/heads/main'],
['config', '--get', 'push.autoSetupRemote'],
['config', '--local', 'push.autoSetupRemote', 'true']
])
})
it('qualifies slash-containing local branch names when no remote ref matches', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/release/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
await localDispatcher.callRequest('git.addWorktree', {
repoPath: '/relay/repo',
branchName: 'feature/release',
targetDir: '/relay/wt',
base: 'release/main'
})
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'],
['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'],
[
'worktree',
'add',
'--no-track',
'-b',
'feature/release',
'/relay/wt',
'refs/heads/release/main'
],
['config', '--get', 'push.autoSetupRemote'],
['config', '--local', 'push.autoSetupRemote', 'true']
])
})
it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value
@ -720,6 +790,7 @@ describe('GitHandler', () => {
// No --local set: --get succeeded so we preserve the user's value.
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'],
['config', '--get', 'push.autoSetupRemote']
])
@ -731,6 +802,7 @@ describe('GitHandler', () => {
// to `--local set true` and overwrite that. Mirrors the local addWorktree
// parity case in src/main/git/worktree.test.ts.
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value
@ -742,6 +814,7 @@ describe('GitHandler', () => {
})
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'],
['config', '--get', 'push.autoSetupRemote']
])
@ -753,6 +826,7 @@ describe('GitHandler', () => {
// through to `--local set true`, which would silently overwrite
// whatever value the user actually has.
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset
@ -768,6 +842,7 @@ describe('GitHandler', () => {
).resolves.toBeUndefined()
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'],
['config', '--get', 'push.autoSetupRemote']
])
@ -780,6 +855,7 @@ describe('GitHandler', () => {
it('warns but resolves when --local set fails (write-failure is warn-only)', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails
@ -802,12 +878,12 @@ describe('GitHandler', () => {
warnSpy.mockRestore()
})
it('does not probe or write config when worktree add itself fails', async () => {
it('does not write config when worktree add itself fails', async () => {
// Why: a refactor that moves the config block earlier could try to
// probe against a worktree directory that was never created. Pin the
// ordering invariant: config calls happen only after worktree add
// succeeds.
// probe config against a worktree directory that was never created. Pin
// the ordering invariant: config calls happen only after worktree add succeeds.
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
gitMock.mockRejectedValueOnce(new Error('worktree add failed'))
await expect(
@ -820,6 +896,7 @@ describe('GitHandler', () => {
).rejects.toThrow('worktree add failed')
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['worktree', 'add', '--no-track', '-b', 'feature/fail', '/relay/wt', 'main']
])
})

View File

@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWorktreeAddBaseRef } from './worktree-base-ref'
describe('resolveWorktreeAddBaseRef', () => {
it('leaves fully qualified refs unchanged', async () => {
const refExists = vi.fn()
await expect(resolveWorktreeAddBaseRef('refs/heads/main', refExists)).resolves.toBe(
'refs/heads/main'
)
expect(refExists).not.toHaveBeenCalled()
})
it('qualifies a bare local branch name', async () => {
const refExists = vi.fn(async (ref: string) => ref === 'refs/heads/main')
await expect(resolveWorktreeAddBaseRef('main', refExists)).resolves.toBe('refs/heads/main')
expect(refExists).toHaveBeenCalledWith('refs/heads/main')
})
it('prefers a remote-tracking ref for remote-display names', async () => {
const refExists = vi.fn(async (ref: string) => ref === 'refs/remotes/origin/main')
await expect(resolveWorktreeAddBaseRef('origin/main', refExists)).resolves.toBe(
'refs/remotes/origin/main'
)
expect(refExists).toHaveBeenCalledWith('refs/remotes/origin/main')
})
it('qualifies a slash-containing local branch when no matching remote ref exists', async () => {
const refExists = vi.fn(async (ref: string) => ref === 'refs/heads/release/main')
await expect(resolveWorktreeAddBaseRef('release/main', refExists)).resolves.toBe(
'refs/heads/release/main'
)
expect(refExists.mock.calls.map((call) => call[0])).toEqual([
'refs/remotes/release/main',
'refs/heads/release/main'
])
})
it('keeps unresolvable revisions untouched', async () => {
const refExists = vi.fn(async () => false)
await expect(resolveWorktreeAddBaseRef('abc1234', refExists)).resolves.toBe('abc1234')
})
})

View File

@ -0,0 +1,25 @@
export type WorktreeBaseRefExists = (qualifiedRef: string) => Promise<boolean>
export async function resolveWorktreeAddBaseRef(
baseRef: string,
refExists: WorktreeBaseRefExists
): Promise<string> {
if (baseRef.startsWith('refs/')) {
return baseRef
}
// Why: `git worktree add` receives a revision, so short names can collide
// with tags. Prefer the namespace implied by Orca's base picker: remote
// display names like `origin/main` first, otherwise local branches.
const candidates = baseRef.includes('/')
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
: [`refs/heads/${baseRef}`]
for (const candidate of candidates) {
if (await refExists(candidate)) {
return candidate
}
}
return baseRef
}