mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
1553 lines
59 KiB
TypeScript
1553 lines
59 KiB
TypeScript
/* eslint-disable max-lines -- Why: addWorktree has multiple code paths (no refresh,
|
|
owner reset safety, dirty worktree, diverged branch, custom remote) that each
|
|
need dedicated test coverage. Splitting into separate files would scatter related tests
|
|
without a meaningful boundary. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
|
|
() => ({
|
|
gitExecFileAsyncMock: vi.fn(),
|
|
gitExecFileSyncMock: vi.fn(),
|
|
translateWslOutputPathsMock: vi.fn((output: string) => output)
|
|
})
|
|
)
|
|
|
|
vi.mock('./runner', () => ({
|
|
gitExecFileAsync: gitExecFileAsyncMock,
|
|
gitExecFileSync: gitExecFileSyncMock,
|
|
translateWslOutputPaths: translateWslOutputPathsMock
|
|
}))
|
|
|
|
import {
|
|
addSparseWorktree,
|
|
addWorktree,
|
|
listWorktreeGraph,
|
|
listWorktrees,
|
|
moveWorktree,
|
|
parseWorktreeList,
|
|
removeWorktree,
|
|
WORKTREE_ADD_TIMEOUT_MS
|
|
} from './worktree'
|
|
|
|
describe('listWorktrees in-flight sharing', () => {
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
})
|
|
|
|
// Later describes in this file assume a pristine mock (no global reset).
|
|
afterEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
})
|
|
|
|
it('shares one scan across concurrent calls for the same repo', async () => {
|
|
let resolveScan!: () => void
|
|
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveScan = () => resolve({ stdout: scanOutput })
|
|
})
|
|
)
|
|
|
|
const first = listWorktrees('/repo')
|
|
const second = listWorktrees('/repo')
|
|
resolveScan()
|
|
const [a, b] = await Promise.all([first, second])
|
|
|
|
expect(a).toEqual(b)
|
|
expect(a[0]?.path).toBe('/repo')
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('runs a fresh scan once the shared one has settled', async () => {
|
|
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
|
|
|
await listWorktrees('/repo')
|
|
await listWorktrees('/repo')
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not share scans across different repos', async () => {
|
|
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
|
|
Promise.resolve({
|
|
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
|
|
})
|
|
)
|
|
|
|
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not share scans for callers with an AbortSignal', async () => {
|
|
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
|
const controller = new AbortController()
|
|
|
|
// Why: an aborted shared scan would reject for every caller, so signal
|
|
// callers must keep a private scan.
|
|
await Promise.all([
|
|
listWorktrees('/repo'),
|
|
listWorktrees('/repo', { signal: controller.signal })
|
|
])
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('parseWorktreeList', () => {
|
|
it('parses regular and bare worktree blocks from porcelain output', () => {
|
|
const output = `
|
|
worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree /repo-feature
|
|
HEAD def456
|
|
branch refs/heads/feature/test
|
|
|
|
worktree /repo-bare
|
|
HEAD 0000000
|
|
bare
|
|
`
|
|
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/repo',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/repo-feature',
|
|
head: 'def456',
|
|
branch: 'refs/heads/feature/test',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
},
|
|
{
|
|
path: '/repo-bare',
|
|
head: '0000000',
|
|
branch: '',
|
|
isBare: true,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
})
|
|
|
|
it('returns empty array for empty string input', () => {
|
|
expect(parseWorktreeList('')).toEqual([])
|
|
})
|
|
|
|
it('returns empty array for whitespace-only input', () => {
|
|
expect(parseWorktreeList(' \n\n \n ')).toEqual([])
|
|
})
|
|
|
|
it('parses a single worktree block', () => {
|
|
const output = `worktree /single-repo
|
|
HEAD aaa111
|
|
branch refs/heads/main
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/single-repo',
|
|
head: 'aaa111',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('parses a detached HEAD worktree (no branch line)', () => {
|
|
const output = `worktree /repo-detached
|
|
HEAD abc123
|
|
detached
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/repo-detached',
|
|
head: 'abc123',
|
|
branch: '',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('handles extra blank lines between blocks', () => {
|
|
const output = `worktree /repo-a
|
|
HEAD aaa111
|
|
branch refs/heads/main
|
|
|
|
|
|
worktree /repo-b
|
|
HEAD bbb222
|
|
branch refs/heads/dev
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/repo-a',
|
|
head: 'aaa111',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/repo-b',
|
|
head: 'bbb222',
|
|
branch: 'refs/heads/dev',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
})
|
|
|
|
it('returns entry with empty head when HEAD line is missing', () => {
|
|
const output = `worktree /repo-no-head
|
|
branch refs/heads/main
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/repo-no-head',
|
|
head: '',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('correctly captures worktree path with spaces', () => {
|
|
const output = `worktree /path/to/my worktree
|
|
HEAD ccc333
|
|
branch refs/heads/main
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/path/to/my worktree',
|
|
head: 'ccc333',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('parses NUL-delimited porcelain output with newline paths', () => {
|
|
const output = [
|
|
'worktree /repo',
|
|
'HEAD abc123',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/linked\nworktree',
|
|
'HEAD def456',
|
|
'branch refs/heads/feature/newline',
|
|
''
|
|
].join('\0')
|
|
|
|
expect(parseWorktreeList(output, { nulDelimited: true })).toEqual([
|
|
{
|
|
path: '/repo',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/repo/linked\nworktree',
|
|
head: 'def456',
|
|
branch: 'refs/heads/feature/newline',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
})
|
|
|
|
it('parses multiple bare entries mixed with regular entries', () => {
|
|
const output = `worktree /bare-one
|
|
HEAD 0000000
|
|
bare
|
|
|
|
worktree /regular
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree /bare-two
|
|
HEAD 1111111
|
|
bare
|
|
`
|
|
expect(parseWorktreeList(output)).toEqual([
|
|
{
|
|
path: '/bare-one',
|
|
head: '0000000',
|
|
branch: '',
|
|
isBare: true,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/regular',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
},
|
|
{
|
|
path: '/bare-two',
|
|
head: '1111111',
|
|
branch: '',
|
|
isBare: true,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('listWorktreeGraph', () => {
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReset()
|
|
translateWslOutputPathsMock.mockClear()
|
|
})
|
|
|
|
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree /repo-feature
|
|
HEAD def456
|
|
branch refs/heads/feature/test
|
|
`
|
|
})
|
|
|
|
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
|
{
|
|
path: '/repo',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/repo-feature',
|
|
head: 'def456',
|
|
branch: 'refs/heads/feature/test',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
|
cwd: '/repo'
|
|
})
|
|
})
|
|
|
|
it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
|
|
gitExecFileAsyncMock
|
|
.mockRejectedValueOnce(
|
|
Object.assign(new Error('git usage error'), {
|
|
code: 129,
|
|
stderr: 'usage: git worktree list [<options>]\n'
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree /repo-main-2
|
|
HEAD def456
|
|
branch refs/heads/main-2
|
|
`
|
|
})
|
|
|
|
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
|
{
|
|
path: '/repo',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
},
|
|
{
|
|
path: '/repo-main-2',
|
|
head: 'def456',
|
|
branch: 'refs/heads/main-2',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
|
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
|
])
|
|
})
|
|
|
|
it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
|
|
// Git under a non-English locale translates the usage text, so stderr
|
|
// matching alone misses it; the 129 exit code must still trigger fallback.
|
|
gitExecFileAsyncMock
|
|
.mockRejectedValueOnce(
|
|
Object.assign(new Error('git worktree list --porcelain -z'), {
|
|
code: 129,
|
|
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
})
|
|
|
|
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
|
{
|
|
path: '/repo',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/main',
|
|
isBare: false,
|
|
isMainWorktree: true
|
|
}
|
|
])
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
|
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
|
])
|
|
})
|
|
|
|
it('does not retry without -z for non-usage Git failures', async () => {
|
|
// A fatal error (exit 128) is not an unsupported-flag signal, so the -z
|
|
// command must not be silently re-run without it.
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(
|
|
Object.assign(new Error('git worktree list --porcelain -z'), {
|
|
code: 128,
|
|
stderr: 'fatal: unable to read tree\n'
|
|
})
|
|
)
|
|
|
|
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
|
|
])
|
|
})
|
|
|
|
it('returns an empty graph for paths Git reports as non-repositories', async () => {
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
|
|
|
|
await expect(listWorktreeGraph('/not-a-repo')).resolves.toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('addWorktree', () => {
|
|
const resolveRemoteBase = () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
}
|
|
|
|
const resolveCreationBaseConfigWrite = () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
}
|
|
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReset()
|
|
translateWslOutputPathsMock.mockClear()
|
|
})
|
|
|
|
it('creates the worktree without touching the local base ref by default', async () => {
|
|
resolveRemoteBase()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
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/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',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo-feature' }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
|
|
[['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }]
|
|
])
|
|
})
|
|
|
|
it('checks out a selected existing local branch without creating a new branch', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
|
checkoutExistingBranch: true
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[
|
|
['worktree', 'add', '/repo-feature', 'feature/test'],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
]
|
|
])
|
|
})
|
|
|
|
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
|
|
// Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
|
|
// `git worktree add` for minutes. Assert the runner receives a non-zero
|
|
// timeout so a stuck create fails fast instead of hanging forever.
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
|
checkoutExistingBranch: true
|
|
})
|
|
|
|
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
|
|
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
|
|
)
|
|
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS })
|
|
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('does not write branch base config when no base branch is provided', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/no-base')
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[
|
|
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
|
])
|
|
})
|
|
|
|
it('warns and unsets stale branch base config when persisting the base fails', async () => {
|
|
resolveRemoteBase()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local --replace-all branch.<branch>.base
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch.<branch>.base
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
await expect(
|
|
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
|
).resolves.toEqual({})
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'addWorktree: failed to set branch.feature/test.base for /repo-feature',
|
|
expect.any(Error)
|
|
)
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'config',
|
|
'--local',
|
|
'--unset-all',
|
|
'branch.feature/test.base'
|
|
])
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('warns but does not throw when push.autoSetupRemote config fails', async () => {
|
|
resolveRemoteBase()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected)
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
await expect(
|
|
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
|
).resolves.toEqual({})
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
|
expect.any(Error)
|
|
)
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('warns and skips --local set when --get fails with non-unset error (e.g. corrupt config)', async () => {
|
|
// Why: exit 1 from `git config --get` means "key unset" — anything else
|
|
// 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
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
await expect(
|
|
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
|
).resolves.toEqual({})
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
|
expect.any(Error)
|
|
)
|
|
// 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',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo-feature' }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
|
])
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
|
|
resolveRemoteBase()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
|
|
|
// 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',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo-feature' }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
|
])
|
|
})
|
|
|
|
it('treats --get success with empty stdout as "already set" (key present but blank)', async () => {
|
|
// 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
|
|
resolveCreationBaseConfigWrite()
|
|
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',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo-feature' }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
|
])
|
|
})
|
|
|
|
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(
|
|
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
|
).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',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
]
|
|
])
|
|
})
|
|
|
|
it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', async () => {
|
|
const worktreeListOutput =
|
|
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo)
|
|
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
|
[
|
|
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
|
|
{ cwd: '/repo' }
|
|
],
|
|
[['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }],
|
|
[['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
|
[['merge-base', '--is-ancestor', 'old-main', 'remote-main'], { cwd: '/repo' }],
|
|
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
|
|
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
|
|
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
|
|
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
|
|
[['reset', '--hard', 'remote-main'], { cwd: '/repo' }],
|
|
[
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/repo-feature',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
{ cwd: '/repo-feature' }
|
|
],
|
|
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
|
|
[['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }]
|
|
])
|
|
})
|
|
|
|
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', async () => {
|
|
const worktreeListOutput =
|
|
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo-main-wt)
|
|
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls[6]).toEqual([
|
|
['status', '--porcelain', '--untracked-files=no'],
|
|
expect.objectContaining({ cwd: '/repo-main-wt' })
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[8]).toEqual([
|
|
['status', '--porcelain', '--untracked-files=no'],
|
|
expect.objectContaining({ cwd: '/repo-main-wt' })
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[9]).toEqual([
|
|
['reset', '--hard', 'remote-main'],
|
|
expect.objectContaining({ cwd: '/repo-main-wt' })
|
|
])
|
|
})
|
|
|
|
it('fast-forwards local base via update-ref when localBranch is not checked out', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // update-ref refs/heads/main remote-main old-main
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'updated',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main'
|
|
})
|
|
// Compare-and-swap form (expected old OID) so a concurrent ref move is a no-op.
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'remote-main',
|
|
'old-main'
|
|
])
|
|
// No worktree owns the branch, so no working tree is reset.
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'reset',
|
|
'--hard',
|
|
'remote-main'
|
|
])
|
|
})
|
|
|
|
it('skips local base refresh when the owner worktree becomes dirty before mutation', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list before mutation
|
|
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain before mutation
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'skipped_dirty_worktree',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main',
|
|
ownerWorktreePath: '/repo'
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'remote-main',
|
|
'old-main'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'reset',
|
|
'--hard',
|
|
'refs/heads/main'
|
|
])
|
|
})
|
|
|
|
it('skips local base refresh when the owner worktree switches branches before mutation', async () => {
|
|
const firstWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
const secondWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: firstWorktreeListOutput }) // worktree list during evaluation
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
|
|
.mockResolvedValueOnce({ stdout: secondWorktreeListOutput }) // worktree list before mutation
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'skipped_error',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main'
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'remote-main',
|
|
'old-main'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'reset',
|
|
'--hard',
|
|
'refs/heads/main'
|
|
])
|
|
})
|
|
|
|
it('skips local base refresh when owner revalidation cannot list worktrees', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during evaluation
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
|
|
.mockRejectedValueOnce(new Error('worktree list failed')) // worktree list before mutation
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'skipped_error',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main'
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'remote-main',
|
|
'old-main'
|
|
])
|
|
})
|
|
|
|
it('skips update when the owning worktree is dirty', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'skipped_dirty_worktree',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main',
|
|
ownerWorktreePath: '/repo'
|
|
})
|
|
|
|
// No reset --hard or update-ref — just base resolution, drift check, local/remote
|
|
// OIDs, ancestry check, worktree list, status, worktree add, and config writes.
|
|
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(11)
|
|
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
|
|
'rev-parse',
|
|
'--verify',
|
|
'--quiet',
|
|
'refs/remotes/origin/main^{commit}'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/repo-feature',
|
|
'refs/remotes/origin/main'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[8]?.[0]).toEqual([
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([
|
|
'config',
|
|
'--get',
|
|
'push.autoSetupRemote'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[10]?.[0]).toEqual([
|
|
'config',
|
|
'--local',
|
|
'push.autoSetupRemote',
|
|
'true'
|
|
])
|
|
})
|
|
|
|
it('skips updating the local branch when it has diverged', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
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/test', 'origin/main', true)
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[
|
|
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/repo-feature',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
],
|
|
[
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
],
|
|
[
|
|
['config', '--local', 'push.autoSetupRemote', 'true'],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
]
|
|
])
|
|
})
|
|
|
|
it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit}
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
|
|
|
expect(result.localBaseRefRefresh).toEqual({
|
|
status: 'skipped_not_fast_forward',
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main'
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
|
[
|
|
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
['rev-parse', '--verify', 'refs/heads/main^{commit}'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
['merge-base', '--is-ancestor', 'new-local', 'remote-main'],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/repo-feature',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
expect.objectContaining({ cwd: '/repo' })
|
|
],
|
|
[
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
],
|
|
[
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
],
|
|
[
|
|
['config', '--local', 'push.autoSetupRemote', 'true'],
|
|
expect.objectContaining({ cwd: '/repo-feature' })
|
|
]
|
|
])
|
|
})
|
|
|
|
it('suggests updating the local base ref when setting is off and the branch is safely behind', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree(
|
|
'/repo',
|
|
'/repo-feature',
|
|
'feature/test',
|
|
'origin/main',
|
|
false,
|
|
false,
|
|
{ suggestLocalBaseRefUpdate: true }
|
|
)
|
|
|
|
expect(result.localBaseRefUpdateSuggestion).toEqual({
|
|
baseRef: 'origin/main',
|
|
localBranch: 'main',
|
|
behind: 2
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
|
|
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
|
|
{ cwd: '/repo' }
|
|
])
|
|
})
|
|
|
|
it('uses normalized branch metadata for slash-containing remotes', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/foo/bar/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/foo/bar/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree(
|
|
'/repo',
|
|
'/repo-feature',
|
|
'feature/test',
|
|
'foo/bar/main',
|
|
false,
|
|
false,
|
|
{
|
|
suggestLocalBaseRefUpdate: true,
|
|
remoteTrackingBase: {
|
|
base: 'foo/bar/main',
|
|
branch: 'main',
|
|
ref: 'refs/remotes/foo/bar/main'
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(result.localBaseRefUpdateSuggestion).toEqual({
|
|
baseRef: 'foo/bar/main',
|
|
localBranch: 'main',
|
|
behind: 2
|
|
})
|
|
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
|
|
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/foo/bar/main'],
|
|
{ cwd: '/repo' }
|
|
])
|
|
})
|
|
|
|
it('does not suggest updating the local base ref when its owner worktree is dirty', async () => {
|
|
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree(
|
|
'/repo',
|
|
'/repo-feature',
|
|
'feature/test',
|
|
'origin/main',
|
|
false,
|
|
false,
|
|
{ suggestLocalBaseRefUpdate: true }
|
|
)
|
|
|
|
expect(result).toEqual({})
|
|
})
|
|
|
|
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
|
|
resolveCreationBaseConfigWrite()
|
|
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', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
|
])
|
|
})
|
|
|
|
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
|
|
resolveCreationBaseConfigWrite()
|
|
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',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/release.base',
|
|
'refs/heads/release/main'
|
|
],
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
['config', '--local', 'push.autoSetupRemote', 'true']
|
|
])
|
|
})
|
|
|
|
it('does not report a local base refresh for slash-containing local branch names', 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
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
const result = await addWorktree(
|
|
'/repo',
|
|
'/repo-feature',
|
|
'feature/release',
|
|
'release/main',
|
|
true
|
|
)
|
|
|
|
expect(result).toEqual({})
|
|
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',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/release.base',
|
|
'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
|
|
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
|
|
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit}
|
|
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
|
|
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
|
|
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck
|
|
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
|
|
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
|
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
|
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
|
|
|
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([
|
|
'rev-list',
|
|
'--left-right',
|
|
'--count',
|
|
'refs/heads/main...refs/remotes/upstream/main'
|
|
])
|
|
expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([
|
|
'reset',
|
|
'--hard',
|
|
'remote-upstream-main'
|
|
])
|
|
})
|
|
|
|
it('unsets branch base config during sparse setup cleanup after creation succeeds', async () => {
|
|
const beforeRemoval =
|
|
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n'
|
|
resolveRemoteBase()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
|
resolveCreationBaseConfigWrite()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('sparse setup failed')) // sparse-checkout init
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch.<branch>.base
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // worktree list before remove
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -D (rollback force-deletes the fresh branch)
|
|
|
|
await expect(
|
|
addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['src'], 'origin/main')
|
|
).rejects.toThrow('sparse setup failed')
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'config',
|
|
'--local',
|
|
'--unset-all',
|
|
'branch.feature/test.base'
|
|
])
|
|
// Why: rolling back a failed creation force-deletes the just-created branch
|
|
// (`-D`) — it has no user commits to protect, unlike a user-initiated delete.
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'branch',
|
|
'-D',
|
|
'--',
|
|
'feature/test'
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('moveWorktree', () => {
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
})
|
|
|
|
it('runs `git worktree move` from the repo with old and new paths', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
|
await moveWorktree('/repo', '/ws/cunner', '/ws/worktree-creation-spinner')
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
|
['worktree', 'move', '/ws/cunner', '/ws/worktree-creation-spinner'],
|
|
{ cwd: '/repo' }
|
|
)
|
|
})
|
|
|
|
it('propagates git failures so the caller can fall back', async () => {
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: destination exists'))
|
|
await expect(moveWorktree('/repo', '/ws/cunner', '/ws/taken')).rejects.toThrow(
|
|
'destination exists'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('removeWorktree', () => {
|
|
const beforeRemoval =
|
|
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n'
|
|
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReset()
|
|
translateWslOutputPathsMock.mockClear()
|
|
})
|
|
|
|
it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
|
// Git refuses to delete an unmerged branch with `-d`.
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d
|
|
|
|
// Should not throw — the unmerged branch is preserved, not force-deleted.
|
|
await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toEqual({
|
|
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
|
})
|
|
|
|
const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0])
|
|
expect(calls).toContainEqual(['branch', '-d', '--', 'feature/test'])
|
|
expect(calls).not.toContainEqual(['branch', '-D', '--', 'feature/test'])
|
|
})
|
|
|
|
it('deletes the branch when `branch -d` succeeds (fully merged)', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
|
|
|
|
await removeWorktree('/repo', '/repo-feature', false)
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'branch',
|
|
'-d',
|
|
'--',
|
|
'feature/test'
|
|
])
|
|
})
|
|
|
|
it('reuses known removed worktree metadata instead of relisting before removal', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
|
|
|
|
await removeWorktree('/repo', '/repo-feature', false, {
|
|
knownRemovedWorktree: {
|
|
branch: 'refs/heads/feature/test',
|
|
head: 'def456'
|
|
}
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
|
['worktree', 'remove', '/repo-feature'],
|
|
['branch', '-d', '--', 'feature/test']
|
|
])
|
|
})
|
|
|
|
it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
|
gitExecFileAsyncMock.mockRejectedValueOnce(
|
|
new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'")
|
|
) // branch -d hits stale worktree metadata
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree prune
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d retry succeeds
|
|
|
|
await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toEqual({})
|
|
|
|
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
|
['worktree', 'list', '--porcelain', '-z'],
|
|
['worktree', 'remove', '/repo-feature'],
|
|
['branch', '-d', '--', 'feature/test'],
|
|
['worktree', 'prune'],
|
|
['branch', '-d', '--', 'feature/test']
|
|
])
|
|
})
|
|
})
|