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:
parent
7cf154592c
commit
6e949d6ec8
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue