mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import * as path from 'node:path'
|
|
import { GitCapabilityCache } from '../shared/git-capability-cache'
|
|
import type { GitExec } from './git-handler-ops'
|
|
import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops'
|
|
|
|
function removeWorktreeWithCapabilityCache(
|
|
git: GitExec,
|
|
params: Parameters<typeof removeWorktreeOp>[1]
|
|
) {
|
|
return removeWorktreeOp(git, params, new GitCapabilityCache())
|
|
}
|
|
|
|
function worktreeList(...entries: { path: string; branch?: string }[]): string {
|
|
return entries
|
|
.map((entry, index) =>
|
|
[
|
|
`worktree ${entry.path}`,
|
|
`HEAD ${index}`,
|
|
...(entry.branch ? [`branch refs/heads/${entry.branch}`] : [])
|
|
].join('\n')
|
|
)
|
|
.join('\n\n')
|
|
}
|
|
|
|
function resolvedRepoPath(): string {
|
|
return path.posix.resolve('/repo-feature', '/repo/.git', '..')
|
|
}
|
|
|
|
describe('addWorktreeOp', () => {
|
|
it('writes durable branch base config after creating an SSH new-branch worktree', async () => {
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: '/repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '/repo-feature',
|
|
base: 'origin/main'
|
|
},
|
|
'linux'
|
|
)
|
|
|
|
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/repo-feature',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
['config', '--get', 'push.autoSetupRemote']
|
|
])
|
|
expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'config',
|
|
'--local',
|
|
'branch.feature/test.remote',
|
|
'origin'
|
|
])
|
|
expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'config',
|
|
'--local',
|
|
'branch.feature/test.merge',
|
|
'refs/heads/main'
|
|
])
|
|
})
|
|
|
|
it('does not write branch base config when checking out an existing SSH branch', async () => {
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: '/repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '/repo-feature',
|
|
base: 'origin/main',
|
|
checkoutExistingBranch: true
|
|
},
|
|
'linux'
|
|
)
|
|
|
|
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
|
['worktree', 'add', '/repo-feature', 'feature/test']
|
|
])
|
|
})
|
|
|
|
it('does not write branch base config when SSH creation has no base', async () => {
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: '/repo',
|
|
branchName: 'feature/no-base',
|
|
targetDir: '/repo-feature'
|
|
},
|
|
'linux'
|
|
)
|
|
|
|
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
|
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
|
|
['config', '--get', 'push.autoSetupRemote']
|
|
])
|
|
})
|
|
|
|
it('enables long paths when the SSH execution host is Windows', async () => {
|
|
// Why: only the host's OS matters — a macOS client can drive a Windows SSH host,
|
|
// which hits the same MAX_PATH ceiling (issue #15785).
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: 'C:\\repo',
|
|
branchName: 'feature/test',
|
|
targetDir: 'C:\\repo-feature',
|
|
checkoutExistingBranch: true
|
|
},
|
|
'win32'
|
|
)
|
|
|
|
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
|
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test']
|
|
])
|
|
})
|
|
|
|
it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => {
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: 'C:\\repo',
|
|
branchName: 'feature/test',
|
|
targetDir: 'C:\\repo-feature',
|
|
noCheckout: true
|
|
},
|
|
'win32'
|
|
)
|
|
|
|
expect(git.mock.calls[0][0]).toEqual([
|
|
'-c',
|
|
'core.longpaths=true',
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'--no-checkout',
|
|
'-b',
|
|
'feature/test',
|
|
'C:\\repo-feature'
|
|
])
|
|
})
|
|
|
|
it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => {
|
|
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
|
|
|
await addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
|
checkoutExistingBranch: true
|
|
},
|
|
'win32'
|
|
)
|
|
|
|
expect(git.mock.calls[0][0]).toEqual([
|
|
'worktree',
|
|
'add',
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
|
'feature/test'
|
|
])
|
|
})
|
|
|
|
it('warns and unsets stale branch base config when SSH base persistence fails', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'config' && args[2] === '--replace-all') {
|
|
throw new Error('config locked')
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await expect(
|
|
addWorktreeOp(
|
|
git,
|
|
{
|
|
repoPath: '/repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '/repo-feature',
|
|
base: 'origin/main'
|
|
},
|
|
'linux'
|
|
)
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'relay addWorktree: failed to set branch.feature/test.base for /repo-feature',
|
|
expect.any(Error)
|
|
)
|
|
expect(git.mock.calls.map((call) => call[0])).toContainEqual([
|
|
'config',
|
|
'--local',
|
|
'--unset-all',
|
|
'branch.feature/test.base'
|
|
])
|
|
warnSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('removeWorktreeOp', () => {
|
|
it('rejects a locked SSH worktree before invoking remove', async () => {
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
return {
|
|
stdout: `${worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)}\nlocked remote agent`,
|
|
stderr: ''
|
|
}
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await expect(
|
|
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent.')
|
|
expect(git).not.toHaveBeenCalledWith(
|
|
['worktree', 'remove', '/repo-feature'],
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('deletes the now-unused branch after removing an SSH worktree', async () => {
|
|
const calls: string[] = []
|
|
let listCount = 0
|
|
const git = vi.fn<GitExec>(async (args, cwd) => {
|
|
calls.push(`${cwd}$ ${args.join(' ')}`)
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
listCount += 1
|
|
return {
|
|
stdout:
|
|
listCount === 1
|
|
? worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)
|
|
: worktreeList({ path: '/repo', branch: 'main' }),
|
|
stderr: ''
|
|
}
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
|
|
expect(calls).toEqual([
|
|
'/repo-feature$ rev-parse --git-common-dir',
|
|
`${resolvedRepoPath()}$ worktree list --porcelain -z`,
|
|
`${resolvedRepoPath()}$ worktree remove /repo-feature`,
|
|
`${resolvedRepoPath()}$ branch -d -- feature/test`
|
|
])
|
|
})
|
|
|
|
it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => {
|
|
const calls: string[] = []
|
|
let listCount = 0
|
|
const git = vi.fn<GitExec>(async (args, cwd) => {
|
|
calls.push(`${cwd}$ ${args.join(' ')}`)
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
listCount += 1
|
|
return {
|
|
stdout:
|
|
listCount === 1
|
|
? worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)
|
|
: worktreeList({ path: '/repo', branch: 'main' }),
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'remove' && !args.includes('--force')) {
|
|
throw Object.assign(new Error('git worktree remove failed'), {
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
})
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
|
|
expect(calls).toEqual([
|
|
'/repo-feature$ rev-parse --git-common-dir',
|
|
`${resolvedRepoPath()}$ worktree list --porcelain -z`,
|
|
`${resolvedRepoPath()}$ worktree remove /repo-feature`,
|
|
'/repo-feature$ status --porcelain --untracked-files=all',
|
|
`${resolvedRepoPath()}$ worktree remove --force /repo-feature`,
|
|
`${resolvedRepoPath()}$ branch -d -- feature/test`
|
|
])
|
|
})
|
|
|
|
it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => {
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
return {
|
|
stdout: worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
),
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'remove') {
|
|
throw Object.assign(new Error('git worktree remove failed'), {
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
})
|
|
}
|
|
if (args[0] === 'status') {
|
|
return { stdout: ' M sub\n', stderr: '' }
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await expect(
|
|
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
).rejects.toThrow('Worktree has uncommitted or untracked changes.')
|
|
expect(git).not.toHaveBeenCalledWith(
|
|
['worktree', 'remove', '--force', '/repo-feature'],
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('does not force-retry when the caller already forced SSH removal', async () => {
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
return {
|
|
stdout: worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
),
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'remove') {
|
|
throw Object.assign(new Error('git worktree remove failed'), {
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
})
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await expect(
|
|
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true })
|
|
).rejects.toThrow('git worktree remove failed')
|
|
expect(
|
|
git.mock.calls.filter(
|
|
([args]) => args[0] === 'worktree' && args[1] === 'remove' && args.includes('--force')
|
|
)
|
|
).toHaveLength(1)
|
|
expect(git).not.toHaveBeenCalledWith(
|
|
['status', '--porcelain', '--untracked-files=all'],
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => {
|
|
let listCount = 0
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
listCount += 1
|
|
return {
|
|
stdout:
|
|
listCount === 1
|
|
? worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)
|
|
: worktreeList({ path: '/repo', branch: 'main' }),
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'branch' && args[1] === '-d') {
|
|
throw new Error('error: the branch feature/test is not fully merged')
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
// The unmerged-branch refusal must be surfaced without failing workspace removal.
|
|
await expect(
|
|
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
).resolves.toEqual({
|
|
preservedBranch: { branchName: 'feature/test', head: '1' }
|
|
})
|
|
|
|
expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
|
|
expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String))
|
|
})
|
|
|
|
it('force-deletes the just-created branch during failed sparse setup rollback', async () => {
|
|
let listCount = 0
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
listCount += 1
|
|
return {
|
|
stdout:
|
|
listCount === 1
|
|
? worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)
|
|
: worktreeList({ path: '/repo', branch: 'main' }),
|
|
stderr: ''
|
|
}
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await removeWorktreeWithCapabilityCache(git, {
|
|
worktreePath: '/repo-feature',
|
|
force: true,
|
|
forceBranchDelete: true
|
|
})
|
|
|
|
expect(git).toHaveBeenCalledWith(
|
|
['worktree', 'remove', '--force', '/repo-feature'],
|
|
expect.any(String)
|
|
)
|
|
expect(git).toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String))
|
|
expect(git).not.toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
|
|
})
|
|
|
|
it('does not let force override a locked SSH worktree', async () => {
|
|
const git = vi.fn<GitExec>(async (args) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
return {
|
|
stdout: `${worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)}\nlocked remote agent`,
|
|
stderr: ''
|
|
}
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await expect(
|
|
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true })
|
|
).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent')
|
|
|
|
expect(git).not.toHaveBeenCalledWith(
|
|
['worktree', 'remove', '--force', '/repo-feature'],
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('skips branch deletion entirely when deleteBranch is false', async () => {
|
|
const calls: string[] = []
|
|
const git = vi.fn<GitExec>(async (args, cwd) => {
|
|
calls.push(`${cwd}$ ${args.join(' ')}`)
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
return {
|
|
stdout: worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
),
|
|
stderr: ''
|
|
}
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await removeWorktreeWithCapabilityCache(git, {
|
|
worktreePath: '/repo-feature',
|
|
deleteBranch: false
|
|
})
|
|
|
|
expect(calls).toEqual([
|
|
'/repo-feature$ rev-parse --git-common-dir',
|
|
`${resolvedRepoPath()}$ worktree list --porcelain -z`,
|
|
`${resolvedRepoPath()}$ worktree remove /repo-feature`
|
|
])
|
|
})
|
|
|
|
it('keeps the branch when Git reports another SSH worktree still uses it', async () => {
|
|
let listCount = 0
|
|
const git = vi.fn<GitExec>(async (args, _cwd) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: '/repo/.git\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
listCount += 1
|
|
return {
|
|
stdout:
|
|
listCount === 1
|
|
? worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-feature', branch: 'feature/test' }
|
|
)
|
|
: worktreeList(
|
|
{ path: '/repo', branch: 'main' },
|
|
{ path: '/repo-other', branch: 'feature/test' }
|
|
),
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'branch' && args[1] === '-d') {
|
|
throw new Error(
|
|
"error: cannot delete branch 'feature/test' used by worktree at '/repo-other'"
|
|
)
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
|
|
|
|
expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
|
|
expect(git).toHaveBeenCalledWith(['worktree', 'prune'], expect.any(String))
|
|
expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String))
|
|
})
|
|
})
|