8622 lines
273 KiB
TypeScript
8622 lines
273 KiB
TypeScript
/* eslint-disable max-lines */
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import type * as GitUsernameModule from '../git/git-username'
|
||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { join, resolve } from 'node:path'
|
||
import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types'
|
||
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
|
||
|
||
const ORIGINAL_PLATFORM = process.platform
|
||
const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn())
|
||
|
||
function setPlatform(platform: NodeJS.Platform): void {
|
||
Object.defineProperty(process, 'platform', {
|
||
configurable: true,
|
||
value: platform
|
||
})
|
||
}
|
||
|
||
const {
|
||
handleMock,
|
||
removeHandlerMock,
|
||
listWorktreesMock,
|
||
parseWorktreeListMock,
|
||
assertWorktreeCleanForRemovalMock,
|
||
addWorktreeMock,
|
||
addSparseWorktreeMock,
|
||
removeWorktreeMock,
|
||
forceDeleteLocalBranchMock,
|
||
resolveLocalGitUsernameMock,
|
||
getBaseRefDefaultMock,
|
||
resolveDefaultBaseRefWithLocalGitMock,
|
||
resolveDefaultBaseRefViaExecMock,
|
||
getDefaultRemoteMock,
|
||
getBranchConflictKindMock,
|
||
getPRForBranchMock,
|
||
getHostedReviewForBranchMock,
|
||
getWorkItemMock,
|
||
getPullRequestPushTargetMock,
|
||
getEffectiveHooksMock,
|
||
createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScriptMock,
|
||
getEffectiveHooksFromConfigMock,
|
||
getDefaultTabsLaunchMock,
|
||
parseOrcaYamlMock,
|
||
shouldRunSetupForCreateMock,
|
||
buildPosixRunnerScriptMock,
|
||
buildWindowsRunnerScriptMock,
|
||
getSetupRunnerEnvVarsMock,
|
||
runHookMock,
|
||
hasHooksFileMock,
|
||
loadHooksMock,
|
||
computeWorktreePathMock,
|
||
ensurePathWithinWorkspaceMock,
|
||
gitExecFileAsyncMock,
|
||
getSshGitProviderMock,
|
||
getSshFilesystemProviderMock,
|
||
getActiveMultiplexerMock
|
||
} = vi.hoisted(() => ({
|
||
handleMock: vi.fn(),
|
||
removeHandlerMock: vi.fn(),
|
||
listWorktreesMock: vi.fn(),
|
||
parseWorktreeListMock: vi.fn((output: string) =>
|
||
output
|
||
.trim()
|
||
.split(/\n\s*\n/)
|
||
.filter(Boolean)
|
||
.map((block, index) => {
|
||
const lines = block.split(/\r?\n/)
|
||
const path = lines.find((line) => line.startsWith('worktree '))?.slice(9) ?? ''
|
||
const branch = lines.find((line) => line.startsWith('branch '))?.slice(7) ?? ''
|
||
return { path, branch, head: String(index), isBare: false, isMainWorktree: index === 0 }
|
||
})
|
||
),
|
||
assertWorktreeCleanForRemovalMock: vi.fn(),
|
||
addWorktreeMock: vi.fn(),
|
||
addSparseWorktreeMock: vi.fn(),
|
||
removeWorktreeMock: vi.fn(),
|
||
forceDeleteLocalBranchMock: vi.fn(),
|
||
resolveLocalGitUsernameMock: vi.fn(),
|
||
getBaseRefDefaultMock: vi.fn(),
|
||
resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
|
||
resolveDefaultBaseRefViaExecMock: vi.fn(),
|
||
getDefaultRemoteMock: vi.fn(),
|
||
getBranchConflictKindMock: vi.fn(),
|
||
getPRForBranchMock: vi.fn(),
|
||
getHostedReviewForBranchMock: vi.fn(),
|
||
getWorkItemMock: vi.fn(),
|
||
getPullRequestPushTargetMock: vi.fn(),
|
||
getEffectiveHooksMock: vi.fn(),
|
||
createIssueCommandRunnerScriptMock: vi.fn(),
|
||
createSetupRunnerScriptMock: vi.fn(),
|
||
getEffectiveHooksFromConfigMock: vi.fn(),
|
||
getDefaultTabsLaunchMock: vi.fn(),
|
||
parseOrcaYamlMock: vi.fn(),
|
||
shouldRunSetupForCreateMock: vi.fn(),
|
||
buildPosixRunnerScriptMock: vi.fn(),
|
||
buildWindowsRunnerScriptMock: vi.fn(),
|
||
getSetupRunnerEnvVarsMock: vi.fn(),
|
||
runHookMock: vi.fn(),
|
||
hasHooksFileMock: vi.fn(),
|
||
loadHooksMock: vi.fn(),
|
||
computeWorktreePathMock: vi.fn(),
|
||
ensurePathWithinWorkspaceMock: vi.fn(),
|
||
gitExecFileAsyncMock: vi.fn(),
|
||
getSshGitProviderMock: vi.fn(),
|
||
getSshFilesystemProviderMock: vi.fn(),
|
||
getActiveMultiplexerMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('electron', () => ({
|
||
ipcMain: {
|
||
handle: handleMock,
|
||
removeHandler: removeHandlerMock
|
||
}
|
||
}))
|
||
|
||
vi.mock('../git/worktree', () => ({
|
||
listWorktrees: listWorktreesMock,
|
||
listWorktreesStrict: listWorktreesMock,
|
||
parseWorktreeList: parseWorktreeListMock,
|
||
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
|
||
addWorktree: addWorktreeMock,
|
||
addSparseWorktree: addSparseWorktreeMock,
|
||
removeWorktree: removeWorktreeMock,
|
||
forceDeleteLocalBranch: forceDeleteLocalBranchMock
|
||
}))
|
||
|
||
vi.mock('../git/runner', () => ({
|
||
gitExecFileAsync: gitExecFileAsyncMock,
|
||
gitExecFileSync: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../git/repo', () => ({
|
||
getBaseRefDefault: getBaseRefDefaultMock,
|
||
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
|
||
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
|
||
getDefaultRemote: getDefaultRemoteMock,
|
||
getBranchConflictKind: getBranchConflictKindMock
|
||
}))
|
||
|
||
vi.mock('../git/git-username', async () => {
|
||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||
return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock }
|
||
})
|
||
|
||
vi.mock('../github/client', () => ({
|
||
getPRForBranch: getPRForBranchMock,
|
||
getWorkItem: getWorkItemMock,
|
||
getPullRequestPushTarget: getPullRequestPushTargetMock
|
||
}))
|
||
|
||
vi.mock('../source-control/hosted-review', () => ({
|
||
getHostedReviewForBranch: getHostedReviewForBranchMock
|
||
}))
|
||
|
||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||
getSshGitProvider: getSshGitProviderMock,
|
||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
|
||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
|
||
requireSshGitProvider: (connectionId: string) => {
|
||
const provider = getSshGitProviderMock(connectionId)
|
||
if (!provider) {
|
||
throw new Error(
|
||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||
)
|
||
}
|
||
return provider
|
||
}
|
||
}))
|
||
|
||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||
}))
|
||
|
||
vi.mock('./worktree-symlinks', () => ({
|
||
createWorktreeLinkedPaths: vi.fn(),
|
||
removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock
|
||
}))
|
||
|
||
vi.mock('./ssh', () => ({
|
||
getActiveMultiplexer: getActiveMultiplexerMock
|
||
}))
|
||
|
||
vi.mock('../hooks', () => ({
|
||
buildPosixRunnerScript: buildPosixRunnerScriptMock,
|
||
buildWindowsRunnerScript: buildWindowsRunnerScriptMock,
|
||
createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScript: createSetupRunnerScriptMock,
|
||
getEffectiveHooks: getEffectiveHooksMock,
|
||
getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock,
|
||
getDefaultTabsLaunch: getDefaultTabsLaunchMock,
|
||
getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock,
|
||
loadHooks: loadHooksMock,
|
||
parseOrcaYaml: parseOrcaYamlMock,
|
||
runHook: runHookMock,
|
||
hasHooksFile: hasHooksFileMock,
|
||
shouldRunSetupForCreate: shouldRunSetupForCreateMock
|
||
}))
|
||
|
||
vi.mock('./worktree-logic', async (importOriginal) => {
|
||
const actual = (await importOriginal()) as Record<string, unknown>
|
||
return {
|
||
...actual,
|
||
computeWorktreePath: computeWorktreePathMock,
|
||
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
|
||
}
|
||
})
|
||
|
||
const { deleteWorktreeHistoryDirMock } = vi.hoisted(() => ({
|
||
deleteWorktreeHistoryDirMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../terminal-history', () => ({
|
||
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
|
||
}))
|
||
|
||
const { advertisedUrlWatcherForgetWorktreeMock } = vi.hoisted(() => ({
|
||
advertisedUrlWatcherForgetWorktreeMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../ports/advertised-url-watcher', () => ({
|
||
advertisedUrlWatcher: {
|
||
forgetWorktree: advertisedUrlWatcherForgetWorktreeMock
|
||
}
|
||
}))
|
||
|
||
const { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock } =
|
||
vi.hoisted(() => ({
|
||
killAllProcessesForWorktreeMock: vi.fn(),
|
||
clearProviderPtyStateMock: vi.fn(),
|
||
getLocalPtyProviderMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../runtime/worktree-teardown', () => ({
|
||
killAllProcessesForWorktree: killAllProcessesForWorktreeMock
|
||
}))
|
||
|
||
vi.mock('./pty', () => ({
|
||
clearProviderPtyState: clearProviderPtyStateMock,
|
||
getLocalPtyProvider: getLocalPtyProviderMock
|
||
}))
|
||
|
||
import {
|
||
__resetSshWorktreeCreateFetchCacheForTests,
|
||
notifyWorktreesChanged
|
||
} from './worktree-remote'
|
||
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
|
||
import {
|
||
__getDetectedWorktreeScanCacheStatsForTests,
|
||
__resetDetectedWorktreeScanCacheForTests,
|
||
registerWorktreeHandlers
|
||
} from './worktrees'
|
||
|
||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
|
||
|
||
describe('registerWorktreeHandlers', () => {
|
||
const handlers: HandlerMap = {}
|
||
const mainWindow = {
|
||
isDestroyed: () => false,
|
||
webContents: {
|
||
send: vi.fn()
|
||
}
|
||
}
|
||
const store = {
|
||
getRepos: vi.fn(),
|
||
getRepo: vi.fn(),
|
||
getProjects: vi.fn(),
|
||
getSparsePresets: vi.fn(),
|
||
getSettings: vi.fn(),
|
||
getWorktreeMeta: vi.fn(),
|
||
getAllWorktreeMeta: vi.fn(),
|
||
setWorktreeMeta: vi.fn(),
|
||
getProjectHostSetups: vi.fn(),
|
||
removeWorktreeMeta: vi.fn(),
|
||
getAllWorktreeLineage: vi.fn(),
|
||
removeWorktreeLineage: vi.fn()
|
||
}
|
||
let runtimeStub: {
|
||
resolveRemoteTrackingBase: ReturnType<typeof vi.fn>
|
||
hasRemoteTrackingRef: ReturnType<typeof vi.fn>
|
||
getOrStartRemoteTrackingBaseRefresh: ReturnType<typeof vi.fn>
|
||
getOrStartRemoteFetch: ReturnType<typeof vi.fn>
|
||
fetchRemoteWithCache: ReturnType<typeof vi.fn>
|
||
emitWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||
recordOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||
resolveManagedMrBase: ReturnType<typeof vi.fn>
|
||
createTerminal: ReturnType<typeof vi.fn>
|
||
splitTerminal: ReturnType<typeof vi.fn>
|
||
notifyWorktreesChangedForRemoteClients: ReturnType<typeof vi.fn>
|
||
}
|
||
|
||
beforeEach(() => {
|
||
setPlatform(ORIGINAL_PLATFORM)
|
||
__resetSshWorktreeCreateFetchCacheForTests()
|
||
__resetDetectedWorktreeScanCacheForTests()
|
||
invalidateAuthorizedRootsCache()
|
||
for (const m of [
|
||
handleMock,
|
||
removeHandlerMock,
|
||
listWorktreesMock,
|
||
assertWorktreeCleanForRemovalMock,
|
||
addWorktreeMock,
|
||
addSparseWorktreeMock,
|
||
removeWorktreeMock,
|
||
forceDeleteLocalBranchMock,
|
||
resolveLocalGitUsernameMock,
|
||
getBaseRefDefaultMock,
|
||
resolveDefaultBaseRefWithLocalGitMock,
|
||
resolveDefaultBaseRefViaExecMock,
|
||
getDefaultRemoteMock,
|
||
getBranchConflictKindMock,
|
||
getPRForBranchMock,
|
||
getHostedReviewForBranchMock,
|
||
getWorkItemMock,
|
||
getPullRequestPushTargetMock,
|
||
getEffectiveHooksMock,
|
||
getEffectiveHooksFromConfigMock,
|
||
getDefaultTabsLaunchMock,
|
||
parseOrcaYamlMock,
|
||
createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScriptMock,
|
||
buildPosixRunnerScriptMock,
|
||
buildWindowsRunnerScriptMock,
|
||
getSetupRunnerEnvVarsMock,
|
||
shouldRunSetupForCreateMock,
|
||
runHookMock,
|
||
hasHooksFileMock,
|
||
loadHooksMock,
|
||
computeWorktreePathMock,
|
||
ensurePathWithinWorkspaceMock,
|
||
gitExecFileAsyncMock,
|
||
getSshGitProviderMock,
|
||
getSshFilesystemProviderMock,
|
||
getActiveMultiplexerMock,
|
||
mainWindow.webContents.send,
|
||
store.getRepos,
|
||
store.getRepo,
|
||
store.getProjects,
|
||
store.getSparsePresets,
|
||
store.getSettings,
|
||
store.getWorktreeMeta,
|
||
store.getAllWorktreeMeta,
|
||
store.setWorktreeMeta,
|
||
store.getProjectHostSetups,
|
||
store.removeWorktreeMeta,
|
||
store.getAllWorktreeLineage,
|
||
store.removeWorktreeLineage,
|
||
killAllProcessesForWorktreeMock,
|
||
clearProviderPtyStateMock,
|
||
getLocalPtyProviderMock,
|
||
deleteWorktreeHistoryDirMock,
|
||
advertisedUrlWatcherForgetWorktreeMock,
|
||
removeWorktreeLinkedPathsMock
|
||
]) {
|
||
m.mockReset()
|
||
}
|
||
killAllProcessesForWorktreeMock.mockResolvedValue({
|
||
runtimeStopped: 0,
|
||
providerStopped: 0,
|
||
registryStopped: 0
|
||
})
|
||
assertWorktreeCleanForRemovalMock.mockResolvedValue(undefined)
|
||
getLocalPtyProviderMock.mockReturnValue({} as never)
|
||
|
||
for (const key of Object.keys(handlers)) {
|
||
delete handlers[key]
|
||
}
|
||
|
||
handleMock.mockImplementation((channel, handler) => {
|
||
handlers[channel] = handler
|
||
})
|
||
|
||
const repo = {
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
|
||
store.getProjects.mockReturnValue([])
|
||
store.getSparsePresets.mockReturnValue([])
|
||
store.getSettings.mockReturnValue({
|
||
branchPrefix: 'none',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||
workspaceDir: '/workspace'
|
||
})
|
||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||
store.getAllWorktreeMeta.mockReturnValue({})
|
||
store.setWorktreeMeta.mockReturnValue({})
|
||
store.getProjectHostSetups.mockReturnValue([
|
||
{
|
||
id: 'repo-1',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
repoId: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
setupState: 'ready',
|
||
setupMethod: 'legacy-repo',
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
store.getAllWorktreeLineage.mockReturnValue({})
|
||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||
getBaseRefDefaultMock.mockResolvedValue('origin/main')
|
||
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
|
||
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
|
||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||
getBranchConflictKindMock.mockResolvedValue(null)
|
||
getPRForBranchMock.mockResolvedValue(null)
|
||
getHostedReviewForBranchMock.mockResolvedValue(null)
|
||
getWorkItemMock.mockResolvedValue(null)
|
||
getPullRequestPushTargetMock.mockResolvedValue(null)
|
||
// Why: createLocalWorktree can still hit legacy git fetch fallback in
|
||
// narrow unit harnesses. Return a resolved promise so catch/then chains
|
||
// don't trip on undefined.
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
getEffectiveHooksFromConfigMock.mockImplementation(() => getEffectiveHooksMock())
|
||
getDefaultTabsLaunchMock.mockReturnValue(undefined)
|
||
parseOrcaYamlMock.mockReturnValue(null)
|
||
shouldRunSetupForCreateMock.mockReturnValue(false)
|
||
buildPosixRunnerScriptMock.mockImplementation(
|
||
(script: string) => `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
|
||
)
|
||
buildWindowsRunnerScriptMock.mockImplementation((script: string) => script)
|
||
getSetupRunnerEnvVarsMock.mockImplementation(
|
||
(repoArg: { path: string }, worktreePath: string) => ({
|
||
ORCA_ROOT_PATH: repoArg.path,
|
||
ORCA_WORKTREE_PATH: worktreePath,
|
||
ORCA_WORKSPACE_NAME: worktreePath.split('/').at(-1) ?? '',
|
||
CONDUCTOR_ROOT_PATH: repoArg.path,
|
||
GHOSTX_ROOT_PATH: repoArg.path
|
||
})
|
||
)
|
||
createSetupRunnerScriptMock.mockReturnValue({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||
envVars: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
}
|
||
})
|
||
createIssueCommandRunnerScriptMock.mockReturnValue({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
|
||
envVars: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
}
|
||
})
|
||
computeWorktreePathMock.mockImplementation(
|
||
(
|
||
sanitizedName: string,
|
||
repoPath: string,
|
||
settings: { nestWorkspaces: boolean; workspaceDir: string }
|
||
) => {
|
||
if (settings.nestWorkspaces) {
|
||
const repoName =
|
||
repoPath
|
||
.split(/[\\/]/)
|
||
.at(-1)
|
||
?.replace(/\.git$/, '') ?? 'repo'
|
||
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
|
||
}
|
||
return `${settings.workspaceDir}/${sanitizedName}`
|
||
}
|
||
)
|
||
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
|
||
listWorktreesMock.mockResolvedValue([])
|
||
forceDeleteLocalBranchMock.mockResolvedValue(undefined)
|
||
|
||
// Why: createLocalWorktree routes `git fetch` through
|
||
// `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub
|
||
// keeps these tests focused on create-flow semantics; the full
|
||
// cache behavior is covered by fetch-remote-cache.test.ts.
|
||
runtimeStub = {
|
||
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
|
||
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
|
||
getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }),
|
||
getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }),
|
||
fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined),
|
||
emitWorktreeBaseStatus: vi.fn(),
|
||
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
|
||
reconcileWorktreeBaseStatus: vi.fn(),
|
||
clearOptimisticReconcileToken: vi.fn(),
|
||
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }),
|
||
createTerminal: vi.fn().mockResolvedValue({
|
||
handle: 'term-startup',
|
||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||
title: null,
|
||
surface: 'visible'
|
||
}),
|
||
splitTerminal: vi.fn().mockResolvedValue({
|
||
handle: 'term-setup',
|
||
tabId: 'tab-startup',
|
||
paneRuntimeId: -1
|
||
}),
|
||
notifyWorktreesChangedForRemoteClients: vi.fn()
|
||
}
|
||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||
})
|
||
|
||
it('clears the GitLab MR base handler before re-registering IPC handlers', () => {
|
||
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:resolveMrBase')
|
||
expect(handlers['worktrees:resolveMrBase']).toBeDefined()
|
||
})
|
||
|
||
it('clears the branch rename failure-output handler before re-registering IPC handlers', () => {
|
||
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:getBranchRenameFailureOutput')
|
||
expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined()
|
||
})
|
||
|
||
it('prefetches the local default create base through the runtime refresh cache', async () => {
|
||
const repo = {
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: 'origin/master'
|
||
}
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||
baseBranch === 'origin/main' ? remoteBase : null
|
||
)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
|
||
|
||
expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo')
|
||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'origin/master'
|
||
)
|
||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'origin/main'
|
||
)
|
||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
remoteBase
|
||
)
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('uses the runtime remote fetch cache when prefetching a local branch base', async () => {
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-1',
|
||
baseBranch: 'main'
|
||
})
|
||
|
||
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('prefetches origin for local branch bases containing slashes', async () => {
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-1',
|
||
baseBranch: 'Jinwoo-H/vm-improve-2'
|
||
})
|
||
|
||
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalledWith('/workspace/repo', 'Jinwoo-H')
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not prefetch the whole remote for an existing commit SHA base', async () => {
|
||
const sha = 'a'.repeat(40)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-1',
|
||
baseBranch: sha
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(runtimeStub.resolveRemoteTrackingBase).not.toHaveBeenCalled()
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('skips the broad remote fetch when creating from an existing commit SHA base', async () => {
|
||
const sha = 'a'.repeat(40)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/pr-title',
|
||
head: sha,
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'pr-title',
|
||
baseBranch: sha,
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/pr-title',
|
||
'feature/fix',
|
||
sha,
|
||
false
|
||
)
|
||
})
|
||
|
||
it('keeps the broad remote fetch fallback when a commit SHA base is missing locally', async () => {
|
||
const sha = 'b'.repeat(40)
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse' && args.includes(`${sha}^{commit}`)) {
|
||
throw new Error('missing object')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/pr-title',
|
||
head: sha,
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'pr-title',
|
||
baseBranch: sha,
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||
expect(addWorktreeMock).toHaveBeenCalled()
|
||
})
|
||
|
||
it('fetches origin when creating from a local branch base containing slashes', async () => {
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/slash-base',
|
||
head: 'created-sha',
|
||
branch: 'refs/heads/slash-base',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'slash-base',
|
||
baseBranch: 'Jinwoo-H/vm-improve-2',
|
||
branchNameOverride: 'slash-base'
|
||
})
|
||
|
||
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalledWith('/workspace/repo', 'Jinwoo-H')
|
||
expect(addWorktreeMock).toHaveBeenCalled()
|
||
})
|
||
|
||
function mockKnownFeatureWorktree(
|
||
path = '/workspace/feature-wt',
|
||
repoPath = '/workspace/repo'
|
||
): GitWorktreeInfo[] {
|
||
const worktrees: GitWorktreeInfo[] = [
|
||
{
|
||
path: repoPath,
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path,
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]
|
||
listWorktreesMock.mockResolvedValue(worktrees)
|
||
return worktrees
|
||
}
|
||
|
||
function makeWorktreeMeta(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||
return {
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
linkedLinearIssue: null,
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 0,
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
function mockSelectedWslProjectRuntime(): void {
|
||
setPlatform('win32')
|
||
store.getProjects.mockReturnValue([
|
||
{
|
||
id: 'project-1',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
sourceRepoIds: ['repo-1'],
|
||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
}
|
||
|
||
it('strips Orca provenance fields from renderer metadata updates', () => {
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = handlers['worktrees:updateMeta'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
updates: {
|
||
comment: 'keep me',
|
||
isPinned: true,
|
||
orcaCreatedAt: 123,
|
||
orcaCreationSource: 'desktop',
|
||
orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false }
|
||
}
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', {
|
||
comment: 'keep me',
|
||
isPinned: true
|
||
})
|
||
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
|
||
})
|
||
|
||
it('pushes a remote-client invalidation for renames but not read-state updates', () => {
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
handlers['worktrees:updateMeta'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
updates: { isUnread: false }
|
||
})
|
||
// Why: per-click isUnread writes must stay event-free (PR #209), while a
|
||
// rename must reach paired remote clients that no longer poll for titles.
|
||
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled()
|
||
|
||
handlers['worktrees:updateMeta'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
updates: { displayName: 'Renamed workspace' }
|
||
})
|
||
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1')
|
||
})
|
||
|
||
it('does not trust renderer-authored automation provenance during local create', async () => {
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
automationProvenance: {
|
||
kind: 'created-by-automation',
|
||
automationId: 'automation-1',
|
||
automationNameSnapshot: 'Forged',
|
||
automationRunId: 'run-1',
|
||
automationRunTitleSnapshot: 'Forged run',
|
||
createdAt: 123,
|
||
executionTargetType: 'local',
|
||
executionTargetId: 'local',
|
||
projectId: 'repo-1'
|
||
}
|
||
})
|
||
|
||
const persistedMeta = store.setWorktreeMeta.mock.calls.find(
|
||
([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard'
|
||
)?.[1]
|
||
expect(persistedMeta).toBeDefined()
|
||
expect(persistedMeta).not.toHaveProperty('automationProvenance')
|
||
})
|
||
|
||
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
|
||
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
|
||
// rather than failing and forcing the user back to the name picker.
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'improve-dashboard' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard-2',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard-2',
|
||
'improve-dashboard-2',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/improve-dashboard-2',
|
||
branch: 'improve-dashboard-2'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('uses a repo-specific worktree base path when creating local worktrees', async () => {
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null,
|
||
worktreeBasePath: '../worktrees'
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '../worktrees/feature',
|
||
head: 'abc123',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'feature'
|
||
})
|
||
|
||
expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', {
|
||
nestWorkspaces: false,
|
||
workspaceDir: '../worktrees'
|
||
})
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'../worktrees/feature',
|
||
'feature',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::../worktrees/feature',
|
||
expect.objectContaining({
|
||
orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false }
|
||
})
|
||
)
|
||
})
|
||
|
||
it('registers local worktree roots immediately after create', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'base',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
const listWorktreesCallsAfterCreate = listWorktreesMock.mock.calls.length
|
||
await expect(
|
||
resolveRegisteredWorktreePath('/workspace/improve-dashboard', store as never)
|
||
).resolves.toBe(resolve('/workspace/improve-dashboard'))
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(listWorktreesCallsAfterCreate)
|
||
})
|
||
|
||
it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => {
|
||
store.getSettings.mockReturnValue({
|
||
branchPrefix: 'git-username',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||
workspaceDir: '/workspace'
|
||
})
|
||
resolveLocalGitUsernameMock.mockResolvedValue('unused-user')
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/feature-something',
|
||
head: 'abc123',
|
||
branch: 'feature/something',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'feature/something',
|
||
branchNameOverride: 'feature/something'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['check-ref-format', '--branch', 'feature/something'],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-something',
|
||
'feature/something',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(resolveLocalGitUsernameMock).not.toHaveBeenCalled()
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/feature-something',
|
||
branch: 'feature/something'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('creates an additional workspace for folder-mode repos without git worktree add', async () => {
|
||
const repo = {
|
||
id: 'repo-folder',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder' as const
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => ({
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
linkedLinearIssue: null,
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 0,
|
||
...meta
|
||
}))
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-folder',
|
||
name: 'folder-session',
|
||
createdWithAgent: 'codex'
|
||
})) as { worktree: { id: string } }
|
||
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
expect(result.worktree).toEqual(
|
||
expect.objectContaining({
|
||
id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
|
||
repoId: 'repo-folder',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder-session',
|
||
instanceId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||
createdWithAgent: 'codex'
|
||
})
|
||
)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-folder'
|
||
})
|
||
})
|
||
|
||
it('spawns a startup terminal and setup terminal after local worktree registration', async () => {
|
||
addWorktreeMock.mockResolvedValue({})
|
||
listWorktreesMock.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'def',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
createdWithAgent: 'claude',
|
||
startup: {
|
||
command: 'claude --prefill test',
|
||
env: { ORCA_AGENT_MODE: 'direct' },
|
||
telemetry: {
|
||
agent_kind: 'claude',
|
||
launch_source: 'new_workspace_composer',
|
||
request_kind: 'new'
|
||
}
|
||
}
|
||
})) as {
|
||
setup?: unknown
|
||
startupTerminal?: { spawned: boolean; surface?: string }
|
||
timing?: { phases: { phase: string }[] }
|
||
}
|
||
|
||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||
1,
|
||
'id:repo-1::/workspace/improve-dashboard',
|
||
{
|
||
claudeAgentTeamsSourceCommand: 'claude --prefill test',
|
||
command: 'claude --prefill test',
|
||
env: { ORCA_AGENT_MODE: 'direct' },
|
||
launchAgent: 'claude',
|
||
startupCommandDelivery: undefined,
|
||
telemetry: {
|
||
agent_kind: 'claude',
|
||
launch_source: 'new_workspace_composer',
|
||
request_kind: 'new'
|
||
},
|
||
activate: true
|
||
}
|
||
)
|
||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||
2,
|
||
'id:repo-1::/workspace/improve-dashboard',
|
||
{
|
||
title: 'Setup',
|
||
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'),
|
||
env: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
},
|
||
activate: false
|
||
}
|
||
)
|
||
const startupCreateCall = runtimeStub.createTerminal.mock.calls[0]
|
||
const setupCreateCall = runtimeStub.createTerminal.mock.calls[1]
|
||
if (!startupCreateCall || !setupCreateCall) {
|
||
throw new Error('expected startup and setup terminal calls')
|
||
}
|
||
const startupCommand = (startupCreateCall[1] as { command: string }).command
|
||
const setupCommand = (setupCreateCall[1] as { command: string }).command
|
||
expect(startupCommand).toBe('claude --prefill test')
|
||
expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh')
|
||
expect(result.setup).toBeUndefined()
|
||
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
|
||
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
|
||
expect.arrayContaining([
|
||
'git_worktree_add',
|
||
'list_created_worktree',
|
||
'prepare_setup',
|
||
'spawn_startup_terminal'
|
||
])
|
||
)
|
||
})
|
||
|
||
it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
|
||
addWorktreeMock.mockResolvedValue({})
|
||
listWorktreesMock.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'def',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
createSetupRunnerScriptMock.mockReturnValueOnce({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||
envVars: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
},
|
||
waitForAgentStartup: true
|
||
})
|
||
runtimeStub.createTerminal
|
||
.mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' })
|
||
.mockRejectedValueOnce(new Error('setup creation failed'))
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
createdWithAgent: 'claude',
|
||
startup: {
|
||
command: 'claude --prefill test',
|
||
env: { ORCA_AGENT_MODE: 'direct' },
|
||
telemetry: {
|
||
agent_kind: 'claude',
|
||
launch_source: 'new_workspace_composer',
|
||
request_kind: 'new'
|
||
}
|
||
}
|
||
})) as { setup?: { command?: string; runnerScriptPath: string } }
|
||
|
||
expect(result.setup).toEqual(
|
||
expect.objectContaining({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh')
|
||
})
|
||
)
|
||
expect(result.setup?.command).toContain('printf')
|
||
})
|
||
|
||
it('checks out a selected existing local branch exactly', async () => {
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/fix-bug-0',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/fix/bug-0',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix/bug-0',
|
||
baseBranch: 'fix/bug-0',
|
||
branchNameOverride: 'fix/bug-0'
|
||
})
|
||
|
||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-bug-0',
|
||
'fix/bug-0',
|
||
'fix/bug-0',
|
||
false,
|
||
false,
|
||
{ checkoutExistingBranch: true }
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/fix-bug-0',
|
||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/fix-bug-0',
|
||
branch: 'refs/heads/fix/bug-0'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('reuses an existing local branch when the worktree folder is renamed (#5181)', async () => {
|
||
// Why: the reuse checkbox keeps branchNameOverride pinned to the selected
|
||
// branch while the worktree folder is named independently. The backend must
|
||
// still check out that exact branch (no -b) into the renamed folder.
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/my-folder',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/fix/bug-0',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'my-folder',
|
||
baseBranch: 'fix/bug-0',
|
||
branchNameOverride: 'fix/bug-0'
|
||
})
|
||
|
||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/my-folder',
|
||
'fix/bug-0',
|
||
'fix/bug-0',
|
||
false,
|
||
false,
|
||
{ checkoutExistingBranch: true }
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/my-folder',
|
||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/my-folder',
|
||
branch: 'refs/heads/fix/bug-0'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('suffixes only the path when an existing local branch checkout path already exists', async () => {
|
||
const mainWorktree = {
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
computeWorktreePathMock.mockImplementation((sanitizedName: string) =>
|
||
sanitizedName === 'fix-bug-0' ? process.cwd() : `/workspace/${sanitizedName}`
|
||
)
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([mainWorktree])
|
||
.mockResolvedValueOnce([mainWorktree])
|
||
.mockResolvedValueOnce([
|
||
mainWorktree,
|
||
{
|
||
path: '/workspace/fix-bug-0-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/fix/bug-0',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix/bug-0',
|
||
baseBranch: 'fix/bug-0',
|
||
branchNameOverride: 'fix/bug-0'
|
||
})
|
||
|
||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-bug-0-2',
|
||
'fix/bug-0',
|
||
'fix/bug-0',
|
||
false,
|
||
false,
|
||
{ checkoutExistingBranch: true }
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/fix-bug-0-2',
|
||
branch: 'refs/heads/fix/bug-0'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('suffixes branchNameOverride when the requested branch collides', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/something' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/feature-something-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/something-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'feature/something',
|
||
branchNameOverride: 'feature/something'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['check-ref-format', '--branch', 'feature/something-2'],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-something-2',
|
||
'feature/something-2',
|
||
'origin/main',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('allows a resolver-provided PR branch override to match its remote push target', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/fix' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
getPRForBranchMock.mockResolvedValueOnce({
|
||
number: 42,
|
||
title: 'Selected PR',
|
||
state: 'open',
|
||
url: 'https://example.com/pr/42',
|
||
checksStatus: 'success',
|
||
updatedAt: '2026-05-21T00:00:00Z',
|
||
mergeable: 'UNKNOWN'
|
||
})
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
compareBaseRef: 'refs/remotes/origin/main',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedPR: 42,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title',
|
||
'feature/fix',
|
||
'abc123',
|
||
false
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
|
||
{ cwd: '/workspace/fix-title' }
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/fix-title',
|
||
expect.objectContaining({
|
||
baseRef: 'refs/remotes/origin/main',
|
||
linkedPR: 42
|
||
})
|
||
)
|
||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||
})
|
||
|
||
it('persists an explicit compare base ahead of the checkout remote-tracking base', async () => {
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValueOnce({
|
||
base: 'origin/source-branch',
|
||
remote: 'origin',
|
||
branch: 'source-branch',
|
||
ref: 'refs/remotes/origin/source-branch'
|
||
})
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValueOnce(true)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'origin/source-branch',
|
||
compareBaseRef: 'refs/remotes/origin/main',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedGitLabMR: 7,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/fix-title',
|
||
expect.objectContaining({
|
||
baseRef: 'refs/remotes/origin/main',
|
||
linkedGitLabMR: 7
|
||
})
|
||
)
|
||
})
|
||
|
||
it('allows a selected Bitbucket PR branch override to match its remote push target', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/bitbucket' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/bitbucket-title',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/bitbucket',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
getHostedReviewForBranchMock.mockResolvedValueOnce({
|
||
provider: 'bitbucket',
|
||
number: 11,
|
||
title: 'Bitbucket PR',
|
||
state: 'open',
|
||
url: 'https://bitbucket.org/team/repo/pull-requests/11',
|
||
status: 'success',
|
||
updatedAt: '2026-05-21T00:00:00Z',
|
||
mergeable: 'UNKNOWN'
|
||
})
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'bitbucket-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/bitbucket',
|
||
linkedBitbucketPR: 11,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/bitbucket' }
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/bitbucket-title',
|
||
'feature/bitbucket',
|
||
'abc123',
|
||
false
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/bitbucket-title',
|
||
expect.objectContaining({ linkedBitbucketPR: 11 })
|
||
)
|
||
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
repoPath: '/workspace/repo',
|
||
branch: 'feature/bitbucket',
|
||
linkedBitbucketPR: 11
|
||
})
|
||
)
|
||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('suffixes a selected Bitbucket PR branch when the existing PR is different', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/bitbucket' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/bitbucket-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/bitbucket-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
getHostedReviewForBranchMock.mockResolvedValueOnce({
|
||
provider: 'bitbucket',
|
||
number: 12,
|
||
title: 'Different Bitbucket PR',
|
||
state: 'open',
|
||
url: 'https://bitbucket.org/team/repo/pull-requests/12',
|
||
status: 'success',
|
||
updatedAt: '2026-05-21T00:00:00Z',
|
||
mergeable: 'UNKNOWN'
|
||
})
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'bitbucket-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/bitbucket',
|
||
linkedBitbucketPR: 11,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/bitbucket' }
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/bitbucket-title-2',
|
||
'feature/bitbucket-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/bitbucket-title-2',
|
||
expect.objectContaining({ linkedBitbucketPR: 11 })
|
||
)
|
||
})
|
||
|
||
it('suffixes a matching push target branch without selected PR metadata', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/fix' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title-2',
|
||
'feature/fix-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('suffixes a matching push target branch when selected PR metadata has no PR number', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/fix' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedPR: null,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title-2',
|
||
'feature/fix-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('suffixes a matching push target branch when the existing PR is different', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/fix' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
getPRForBranchMock.mockResolvedValueOnce({
|
||
number: 43,
|
||
title: 'Different PR',
|
||
state: 'open',
|
||
url: 'https://example.com/pr/43',
|
||
checksStatus: 'success',
|
||
updatedAt: '2026-05-21T00:00:00Z',
|
||
mergeable: 'UNKNOWN'
|
||
})
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedPR: 42,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title-2',
|
||
'feature/fix-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('suffixes a selected PR remote conflict when the PR lookup fails', async () => {
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'feature/fix' ? 'remote' : null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable'))
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedPR: 42,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title-2',
|
||
'feature/fix-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('checks out an unused existing PR branch only when it is at the resolved head SHA', async () => {
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/fix-title',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title',
|
||
'feature/fix',
|
||
'abc123',
|
||
false,
|
||
false,
|
||
{ checkoutExistingBranch: true }
|
||
)
|
||
})
|
||
|
||
it('suffixes an existing PR branch when its tip differs from the resolved head SHA', async () => {
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||
return { stdout: 'old123\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
getBranchConflictKindMock.mockResolvedValueOnce('local')
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title-2',
|
||
'feature/fix-2',
|
||
'abc123',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('persists a sanitized artifact title as the worktree display name', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
displayName: ' Fix: dashboards\nfor PRs\u0000 '
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
displayName: 'Fix: dashboards for PRs'
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
displayName: 'Fix: dashboards for PRs'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('persists linked issue and PR metadata during local create', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
})
|
||
})
|
||
|
||
it('persists the selected creation agent during local create', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
createdWithAgent: 'codex'
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
createdWithAgent: 'codex'
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
createdWithAgent: 'codex'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('configures a PR push target during local create', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
pushTarget: {
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git'
|
||
}
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['remote', 'add', 'pr-prateek-orca', 'git@github.com:prateek/orca.git'],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'fetch',
|
||
'pr-prateek-orca',
|
||
'+refs/heads/prateek/fix-sidebar-agents-toggle:refs/remotes/pr-prateek-orca/prateek/fix-sidebar-agents-toggle'
|
||
],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'branch',
|
||
'--set-upstream-to',
|
||
'pr-prateek-orca/prateek/fix-sidebar-agents-toggle',
|
||
'improve-dashboard'
|
||
],
|
||
{ cwd: '/workspace/improve-dashboard' }
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
pushTarget: expect.objectContaining({
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git',
|
||
remoteCreated: true
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('keeps the Orca-created marker when a new worktree reuses an Orca-created fork remote', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
const existingPushTarget = {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'contributor/previous-fix',
|
||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||
remoteCreated: true
|
||
}
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-1::/workspace/previous-fix': makeWorktreeMeta({ pushTarget: existingPushTarget })
|
||
})
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote' && args.length === 1) {
|
||
return { stdout: 'pr-contributor-orca\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
pushTarget: {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'contributor/new-fix',
|
||
remoteUrl: 'https://github.com/contributor/orca.git'
|
||
}
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||
['remote', 'add', expect.any(String), expect.any(String)],
|
||
expect.any(Object)
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
pushTarget: expect.objectContaining({
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'contributor/new-fix',
|
||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||
remoteCreated: true
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('returns the PR head push target when resolving a fork PR base', async () => {
|
||
getPullRequestPushTargetMock.mockResolvedValue({
|
||
pushTarget: {
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git'
|
||
}
|
||
})
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 1738,
|
||
headRefName: 'prateek/fix-sidebar-agents-toggle',
|
||
isCrossRepository: true
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1738/head'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(result).toMatchObject({
|
||
baseBranch: 'abc123',
|
||
headSha: 'abc123',
|
||
branchNameOverride: 'prateek/fix-sidebar-agents-toggle',
|
||
pushTarget: {
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git'
|
||
}
|
||
})
|
||
})
|
||
|
||
it('returns the same-repo PR head SHA and exact branch override when resolving a PR base', async () => {
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'def456\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 42,
|
||
headRefName: 'feature/add-feature',
|
||
isCrossRepository: false
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'fetch',
|
||
'origin',
|
||
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
|
||
],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['rev-parse', '--verify', 'origin/feature/add-feature'],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(result).toMatchObject({
|
||
baseBranch: 'def456',
|
||
headSha: 'def456',
|
||
branchNameOverride: 'feature/add-feature',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||
})
|
||
})
|
||
|
||
it('routes local worktree creation through the selected WSL project runtime', async () => {
|
||
mockSelectedWslProjectRuntime()
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'base',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
'origin/main',
|
||
false,
|
||
false,
|
||
{ wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({
|
||
cwd: '/workspace/repo',
|
||
wslDistro: 'Ubuntu'
|
||
})
|
||
expect(getBranchConflictKindMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'improve-dashboard',
|
||
'origin/main',
|
||
{ wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||
})
|
||
|
||
it('routes fork push target setup through the selected WSL project runtime', async () => {
|
||
mockSelectedWslProjectRuntime()
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/wsl-fork',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/wsl-fork',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'wsl-fork',
|
||
pushTarget: {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'contributor/wsl-fork',
|
||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||
}
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['check-ref-format', '--branch', 'contributor/wsl-fork'],
|
||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['remote', 'add', 'pr-contributor-orca', 'git@github.com:contributor/orca.git'],
|
||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'fetch',
|
||
'pr-contributor-orca',
|
||
'+refs/heads/contributor/wsl-fork:refs/remotes/pr-contributor-orca/contributor/wsl-fork'
|
||
],
|
||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'],
|
||
{ cwd: '/workspace/wsl-fork', wslDistro: 'Ubuntu' }
|
||
)
|
||
})
|
||
|
||
it('routes selected PR branch conflict lookup through the selected WSL project runtime', async () => {
|
||
mockSelectedWslProjectRuntime()
|
||
getBranchConflictKindMock.mockResolvedValueOnce('remote')
|
||
getPRForBranchMock.mockResolvedValueOnce({
|
||
number: 42,
|
||
title: 'Selected PR',
|
||
state: 'open',
|
||
url: 'https://example.com/pr/42',
|
||
checksStatus: 'success',
|
||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||
mergeable: 'UNKNOWN'
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/fix-title',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix',
|
||
linkedPR: 42,
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||
})
|
||
|
||
expect(getPRForBranchMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'feature/fix',
|
||
null,
|
||
null,
|
||
null,
|
||
{ localGitExecOptions: { wslDistro: 'Ubuntu' } }
|
||
)
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/fix-title',
|
||
'feature/fix',
|
||
'abc123',
|
||
false,
|
||
false,
|
||
{ wslDistro: 'Ubuntu' }
|
||
)
|
||
})
|
||
|
||
it('routes PR base git calls through the selected WSL project runtime', async () => {
|
||
setPlatform('win32')
|
||
store.getProjects.mockReturnValue([
|
||
{
|
||
id: 'project-1',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
sourceRepoIds: ['repo-1'],
|
||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'def456\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 42,
|
||
headRefName: 'feature/add-feature',
|
||
isCrossRepository: false
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'fetch',
|
||
'origin',
|
||
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
|
||
],
|
||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
['rev-parse', '--verify', 'origin/feature/add-feature'],
|
||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(getDefaultRemoteMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||
expect(result).toMatchObject({
|
||
baseBranch: 'def456',
|
||
headSha: 'def456',
|
||
branchNameOverride: 'feature/add-feature',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||
})
|
||
})
|
||
|
||
it('lists detected worktrees through the selected WSL project runtime', async () => {
|
||
setPlatform('win32')
|
||
store.getProjects.mockReturnValue([
|
||
{
|
||
id: 'project-1',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
sourceRepoIds: ['repo-1'],
|
||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'def456',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||
expect(result).toMatchObject({
|
||
repoId: 'repo-1',
|
||
authoritative: true,
|
||
source: 'git',
|
||
worktrees: [expect.objectContaining({ path: '/workspace/repo' })]
|
||
})
|
||
})
|
||
|
||
it('does not reuse host detected worktree scans for a selected WSL runtime', async () => {
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'host-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'wsl-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
const hostResult = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
setPlatform('win32')
|
||
store.getProjects.mockReturnValue([
|
||
{
|
||
id: 'project-1',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
sourceRepoIds: ['repo-1'],
|
||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
const wslResult = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(hostResult.worktrees[0].head).toBe('host-head')
|
||
expect(wslResult.worktrees[0].head).toBe('wsl-head')
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||
expect(listWorktreesMock).toHaveBeenNthCalledWith(1, '/workspace/repo')
|
||
expect(listWorktreesMock).toHaveBeenNthCalledWith(2, '/workspace/repo', {
|
||
wslDistro: 'Ubuntu'
|
||
})
|
||
})
|
||
|
||
it('reuses a recent authoritative detected worktree scan', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
|
||
expect(first).toEqual(second)
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('coalesces concurrent authoritative detected worktree scans', async () => {
|
||
listWorktreesMock.mockImplementation(async () => {
|
||
await Promise.resolve()
|
||
return [
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]
|
||
})
|
||
|
||
await Promise.all([
|
||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
])
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('rechecks detected worktree metadata while reusing a cached raw scan', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
let currentMeta = makeWorktreeMeta({ isPinned: false })
|
||
store.getWorktreeMeta.mockImplementation(() => currentMeta)
|
||
store.setWorktreeMeta.mockImplementation(() => currentMeta)
|
||
const first = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
currentMeta = makeWorktreeMeta({ isPinned: true })
|
||
const second = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(first.worktrees[0].isPinned).toBe(false)
|
||
expect(second.worktrees[0].isPinned).toBe(true)
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('rescans detected worktrees after the scan cache TTL expires', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/new-worktree',
|
||
head: 'feature-head',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await vi.advanceTimersByTimeAsync(5_001)
|
||
const second = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual([
|
||
'/workspace/repo',
|
||
'/workspace/new-worktree'
|
||
])
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('starts the detected scan cache TTL after a slow scan completes', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
listWorktreesMock
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
setTimeout(
|
||
() =>
|
||
resolve([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]),
|
||
6_000
|
||
)
|
||
})
|
||
)
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/new-worktree',
|
||
head: 'feature-head',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await vi.advanceTimersByTimeAsync(6_000)
|
||
await first
|
||
const second = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo'])
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('invalidates the detected scan cache before worktree change notifications', async () => {
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/new-worktree',
|
||
head: 'feature-head',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||
const second = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(second.worktrees).toHaveLength(2)
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
it('rescans detected worktrees after the local create flow notifies worktree changes', async () => {
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'feature-head',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'feature-head',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
const detected = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
|
||
expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard'
|
||
])
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(3)
|
||
})
|
||
|
||
it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => {
|
||
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
|
||
listWorktreesMock.mockImplementation(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
|
||
})
|
||
)
|
||
store.getAllWorktreeLineage.mockReturnValue({
|
||
'repo-1::/workspace/new-worktree': {
|
||
worktreeId: 'repo-1::/workspace/new-worktree',
|
||
worktreeInstanceId: 'child-instance',
|
||
parentWorktreeId: 'repo-1::/workspace/repo',
|
||
parentWorktreeInstanceId: 'parent-instance',
|
||
origin: 'manual',
|
||
capture: {
|
||
source: 'manual-action',
|
||
confidence: 'explicit'
|
||
},
|
||
createdAt: 0
|
||
}
|
||
})
|
||
|
||
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||
resolveScan([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
await pendingList
|
||
|
||
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('does not retain invalidated detected scans after they settle', async () => {
|
||
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
|
||
listWorktreesMock.mockImplementation(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
|
||
})
|
||
)
|
||
|
||
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
|
||
cacheSize: 0,
|
||
inFlightSize: 1
|
||
})
|
||
|
||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
|
||
cacheSize: 0,
|
||
inFlightSize: 0
|
||
})
|
||
|
||
resolveScan([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
await pendingList
|
||
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
|
||
cacheSize: 0,
|
||
inFlightSize: 0
|
||
})
|
||
})
|
||
|
||
it('does not accumulate scan bookkeeping across prolonged repository churn', async () => {
|
||
store.getRepo.mockImplementation((repoId: string) => ({
|
||
id: repoId,
|
||
path: `/workspace/${repoId}`,
|
||
displayName: repoId,
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
}))
|
||
listWorktreesMock.mockImplementation(async (repoPath: string) => [
|
||
{
|
||
path: repoPath,
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
for (let index = 0; index < 128; index += 1) {
|
||
const repoId = `repo-${index}`
|
||
await handlers['worktrees:listDetected'](null, { repoId })
|
||
notifyWorktreesChanged(mainWindow as never, repoId)
|
||
}
|
||
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
|
||
cacheSize: 0,
|
||
inFlightSize: 0
|
||
})
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(128)
|
||
})
|
||
|
||
it('keeps a replacement scan current after an older scan settles first', async () => {
|
||
const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = []
|
||
listWorktreesMock.mockImplementation(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void)
|
||
})
|
||
)
|
||
const result = [
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]
|
||
|
||
const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||
const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
|
||
resolvers[0](result)
|
||
await staleList
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
|
||
cacheSize: 0,
|
||
inFlightSize: 1
|
||
})
|
||
|
||
resolvers[1](result)
|
||
await replacementList
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
|
||
cacheSize: 1,
|
||
inFlightSize: 0
|
||
})
|
||
})
|
||
|
||
it('does not let an older scan overwrite a replacement that settles first', async () => {
|
||
const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = []
|
||
listWorktreesMock.mockImplementation(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void)
|
||
})
|
||
)
|
||
store.getAllWorktreeLineage.mockReturnValue({
|
||
'repo-1::/workspace/fresh-worktree': {
|
||
worktreeId: 'repo-1::/workspace/fresh-worktree',
|
||
worktreeInstanceId: 'child-instance',
|
||
parentWorktreeId: 'repo-1::/workspace/repo',
|
||
parentWorktreeInstanceId: 'parent-instance',
|
||
origin: 'manual',
|
||
capture: {
|
||
source: 'manual-action',
|
||
confidence: 'explicit'
|
||
},
|
||
createdAt: 0
|
||
}
|
||
})
|
||
const mainWorktree: GitWorktreeInfo = {
|
||
path: '/workspace/repo',
|
||
head: 'main-head',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
|
||
const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||
const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||
await Promise.resolve()
|
||
|
||
resolvers[1]([
|
||
mainWorktree,
|
||
{
|
||
path: '/workspace/fresh-worktree',
|
||
head: 'fresh-head',
|
||
branch: 'refs/heads/fresh-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
await replacementList
|
||
|
||
resolvers[0]([
|
||
mainWorktree,
|
||
{
|
||
path: '/workspace/stale-worktree',
|
||
head: 'stale-head',
|
||
branch: 'refs/heads/stale-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
await staleList
|
||
|
||
const cached = (await handlers['worktrees:listDetected'](null, {
|
||
repoId: 'repo-1'
|
||
})) as { worktrees: Worktree[] }
|
||
expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([
|
||
'/workspace/repo',
|
||
'/workspace/fresh-worktree'
|
||
])
|
||
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
|
||
await expect(
|
||
resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never)
|
||
).resolves.toBe(resolve('/workspace/fresh-worktree'))
|
||
await expect(
|
||
resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never)
|
||
).rejects.toThrow('Access denied: unknown repository or worktree path')
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
|
||
cacheSize: 1,
|
||
inFlightSize: 0
|
||
})
|
||
})
|
||
|
||
it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => {
|
||
const fetchRemoteTrackingRef = vi.fn(async () => {})
|
||
const exec = vi.fn(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'def456\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
getSshGitProviderMock.mockReturnValue({ exec, fetchRemoteTrackingRef })
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 42,
|
||
headRefName: 'feature/add-feature',
|
||
isCrossRepository: false
|
||
})
|
||
|
||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'origin',
|
||
'feature/add-feature',
|
||
'refs/remotes/origin/feature/add-feature'
|
||
)
|
||
expect(exec).not.toHaveBeenCalledWith(expect.arrayContaining(['fetch']), expect.anything())
|
||
expect(result).toMatchObject({
|
||
baseBranch: 'def456',
|
||
headSha: 'def456',
|
||
branchNameOverride: 'feature/add-feature',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||
})
|
||
})
|
||
|
||
it('resolves a fork PR base even when push-target discovery fails', async () => {
|
||
getPullRequestPushTargetMock.mockRejectedValueOnce(new Error('lookup failed'))
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 1849,
|
||
headRefName: 'feat/onboarding-model-choice-782',
|
||
isCrossRepository: true
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(result).toEqual({
|
||
baseBranch: 'abc123',
|
||
headSha: 'abc123',
|
||
branchNameOverride: 'feat/onboarding-model-choice-782'
|
||
})
|
||
})
|
||
|
||
it('falls back to refs/pull/<N>/head when branch fetch fails for a PR', async () => {
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (
|
||
args[0] === 'fetch' &&
|
||
args[2] ===
|
||
'+refs/heads/feat/onboarding-model-choice-782:refs/remotes/origin/feat/onboarding-model-choice-782'
|
||
) {
|
||
throw new Error(
|
||
'fatal: could not find remote ref refs/heads/feat/onboarding-model-choice-782'
|
||
)
|
||
}
|
||
if (args[0] === 'rev-parse') {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 1849,
|
||
headRefName: 'feat/onboarding-model-choice-782'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||
[
|
||
'fetch',
|
||
'origin',
|
||
'+refs/heads/feat/onboarding-model-choice-782:refs/remotes/origin/feat/onboarding-model-choice-782'
|
||
],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(result).toEqual({
|
||
baseBranch: 'abc123',
|
||
headSha: 'abc123',
|
||
branchNameOverride: 'feat/onboarding-model-choice-782'
|
||
})
|
||
})
|
||
|
||
it('does not fall back to refs/pull/<N>/head when branch fetch hits a network failure', async () => {
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (
|
||
args[0] === 'fetch' &&
|
||
args[2] ===
|
||
'+refs/heads/feat/onboarding-model-choice-782:refs/remotes/origin/feat/onboarding-model-choice-782'
|
||
) {
|
||
throw new Error('fatal: unable to access repo: Could not resolve host: github.com')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolvePrBase'](null, {
|
||
repoId: 'repo-1',
|
||
prNumber: 1849,
|
||
headRefName: 'feat/onboarding-model-choice-782'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||
['fetch', 'origin', 'refs/pull/1849/head'],
|
||
expect.anything()
|
||
)
|
||
expect(result).toMatchObject({
|
||
error:
|
||
'Failed to fetch origin/feat/onboarding-model-choice-782: fatal: unable to access repo: Could not resolve host: github.com'
|
||
})
|
||
})
|
||
|
||
it('delegates GitLab MR base resolution through the runtime implementation', async () => {
|
||
runtimeStub.resolveManagedMrBase.mockResolvedValueOnce({
|
||
baseBranch: 'fork-mr-sha',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
|
||
})
|
||
|
||
const result = await handlers['worktrees:resolveMrBase'](null, {
|
||
repoId: 'repo-1',
|
||
mrIid: 42,
|
||
sourceBranch: 'feature/mr',
|
||
isCrossRepository: true
|
||
})
|
||
|
||
expect(runtimeStub.resolveManagedMrBase).toHaveBeenCalledWith({
|
||
repoSelector: 'id:repo-1',
|
||
mrIid: 42,
|
||
sourceBranch: 'feature/mr',
|
||
isCrossRepository: true
|
||
})
|
||
expect(result).toMatchObject({
|
||
baseBranch: 'fork-mr-sha',
|
||
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
|
||
})
|
||
})
|
||
|
||
it('persists linked issue, PR, and selected agent metadata during remote create', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'base123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard',
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
createdWithAgent: 'codex',
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['config', '--get', 'github.user'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['config', '--get', 'user.username'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.listWorktrees).toHaveBeenCalledTimes(1)
|
||
expect(provider.worktreeIsClean).not.toHaveBeenCalled()
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-ssh::/remote/improve-dashboard',
|
||
expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
createdWithAgent: 'codex',
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
createdWithAgent: 'codex',
|
||
linkedLinearIssue: 'ENG-123',
|
||
manualOrder: 123_456
|
||
})
|
||
})
|
||
})
|
||
|
||
it('returns SSH local base refresh skip status when the owning worktree is dirty', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'merge-base') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'log') {
|
||
return { stdout: 'commit-a\ncommit-b\ncommit-c\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'base123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: false, stdout: ' M package.json\n' }),
|
||
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getSettings.mockReturnValue({
|
||
branchPrefix: 'none',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: true,
|
||
workspaceDir: '/workspace'
|
||
})
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', {
|
||
includeUntracked: false
|
||
})
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['reset', '--hard', 'refs/remotes/origin/main'],
|
||
expect.any(String)
|
||
)
|
||
expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
localBaseRefRefresh: {
|
||
status: 'skipped_dirty_worktree',
|
||
baseRef: 'origin/main',
|
||
localBranch: 'main',
|
||
ownerWorktreePath: '/remote/repo'
|
||
}
|
||
})
|
||
)
|
||
})
|
||
|
||
it('refreshes SSH local base through the narrow relay RPC when the setting is on', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'merge-base') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'log') {
|
||
return { stdout: 'commit-a\ncommit-b\n', stderr: '' }
|
||
}
|
||
throw new Error(`unexpected generic exec: ${args.join(' ')}`)
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'base123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getSettings.mockReturnValue({
|
||
branchPrefix: 'none',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: true,
|
||
workspaceDir: '/workspace'
|
||
})
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
|
||
repoPath: '/remote/repo',
|
||
fullRef: 'refs/heads/main',
|
||
remoteTrackingRef: 'refs/remotes/origin/main',
|
||
ownerWorktreePath: '/remote/repo'
|
||
})
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['reset', '--hard', 'refs/remotes/origin/main'],
|
||
expect.any(String)
|
||
)
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'],
|
||
expect.any(String)
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
localBaseRefRefresh: {
|
||
status: 'updated',
|
||
baseRef: 'origin/main',
|
||
localBranch: 'main',
|
||
ownerWorktreePath: '/remote/repo'
|
||
}
|
||
})
|
||
)
|
||
})
|
||
|
||
it('returns SSH local base update suggestion when a full local base ref is safely behind', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'refs/remotes/origin/main'
|
||
}
|
||
let registeredRoots = false
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'merge-base' || args[0] === 'log') {
|
||
if (!registeredRoots) {
|
||
throw new Error('Path outside authorized workspace')
|
||
}
|
||
return {
|
||
stdout: args[0] === 'log' ? 'commit-a\ncommit-b\ncommit-c\ncommit-d\n' : '',
|
||
stderr: ''
|
||
}
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockImplementation(async () => {
|
||
if (!registeredRoots) {
|
||
throw new Error('No workspace roots registered yet')
|
||
}
|
||
return [
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'base123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]
|
||
}),
|
||
worktreeIsClean: vi.fn().mockImplementation(async () => {
|
||
if (!registeredRoots) {
|
||
throw new Error('Path outside authorized workspace')
|
||
}
|
||
return { clean: true }
|
||
}),
|
||
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockImplementation(async (method: string) => {
|
||
if (method === 'session.registerRoot') {
|
||
registeredRoots = true
|
||
}
|
||
}),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
|
||
'/remote/repo'
|
||
)
|
||
expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||
expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', {
|
||
includeUntracked: false
|
||
})
|
||
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
|
||
repoPath: '/remote/repo',
|
||
fullRef: 'refs/heads/main',
|
||
remoteTrackingRef: 'refs/remotes/origin/main',
|
||
ownerWorktreePath: '/remote/repo',
|
||
checkOnly: true
|
||
})
|
||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||
['reset', '--hard', 'refs/remotes/origin/main'],
|
||
expect.any(String)
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
localBaseRefUpdateSuggestion: {
|
||
baseRef: 'origin/main',
|
||
localBranch: 'main',
|
||
behind: 4
|
||
}
|
||
})
|
||
)
|
||
})
|
||
|
||
it('does not suggest SSH local base updates when the relay cannot refresh local refs', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'refs/remotes/origin/main'
|
||
}
|
||
const methodNotFound = Object.assign(
|
||
new Error('Method not found: git.refreshLocalBaseRefForWorktreeCreate'),
|
||
{ code: -32601 }
|
||
)
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'merge-base') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'log') {
|
||
return { stdout: 'commit-a\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'base123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockRejectedValue(methodNotFound)
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
|
||
repoPath: '/remote/repo',
|
||
fullRef: 'refs/heads/main',
|
||
remoteTrackingRef: 'refs/remotes/origin/main',
|
||
ownerWorktreePath: '/remote/repo',
|
||
checkOnly: true
|
||
})
|
||
expect(result.localBaseRefUpdateSuggestion).toBeUndefined()
|
||
})
|
||
|
||
it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args[1] === '--git-path') {
|
||
return {
|
||
stdout: '/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh\n',
|
||
stderr: ''
|
||
}
|
||
}
|
||
if (args[0] === 'rev-parse') {
|
||
throw new Error('missing local branch')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n setup: pnpm install\n',
|
||
isBinary: false
|
||
}),
|
||
createDir: vi.fn().mockResolvedValue(undefined),
|
||
writeFile: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard',
|
||
setupDecision: 'run'
|
||
})
|
||
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/improve-dashboard/orca.yaml')
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['rev-parse', '--git-path', 'orca/setup-runner.sh'],
|
||
'/remote/improve-dashboard'
|
||
)
|
||
expect(fsProvider.createDir).toHaveBeenCalledWith(
|
||
'/remote/repo/.git/worktrees/improve-dashboard/orca'
|
||
)
|
||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||
'/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh',
|
||
'#!/usr/bin/env bash\nset -e\npnpm install\n'
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
setup: {
|
||
runnerScriptPath: '/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh',
|
||
envVars: expect.objectContaining({
|
||
ORCA_ROOT_PATH: '/remote/repo',
|
||
ORCA_WORKTREE_PATH: '/remote/improve-dashboard'
|
||
})
|
||
}
|
||
})
|
||
)
|
||
})
|
||
|
||
it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/sparse-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/sparse-dashboard',
|
||
isBare: false,
|
||
isSparse: true,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getSparsePresets.mockReturnValue([
|
||
{
|
||
id: 'preset-1',
|
||
repoId: 'repo-ssh',
|
||
name: 'App',
|
||
directories: ['apps/mobile', 'packages/shared'],
|
||
createdAt: 1,
|
||
updatedAt: 1
|
||
}
|
||
])
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'sparse-dashboard',
|
||
sparseCheckout: {
|
||
directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'],
|
||
presetId: 'preset-1'
|
||
}
|
||
})
|
||
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'sparse-dashboard',
|
||
'/remote/sparse-dashboard',
|
||
{ base: 'origin/main', noCheckout: true }
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['sparse-checkout', 'init', '--cone'],
|
||
'/remote/sparse-dashboard'
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'],
|
||
'/remote/sparse-dashboard'
|
||
)
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['checkout', 'sparse-dashboard'],
|
||
'/remote/sparse-dashboard'
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-ssh::/remote/sparse-dashboard',
|
||
expect.objectContaining({
|
||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||
baseRef: 'refs/remotes/origin/main',
|
||
sparseBaseRef: 'refs/remotes/origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
isSparse: true,
|
||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||
sparseBaseRef: 'refs/remotes/origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'abc123'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
|
||
return { stdout: 'abc123\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/fix-title-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const fsProvider = {
|
||
stat: vi.fn().mockImplementation(async (pathValue: string) => {
|
||
if (pathValue === '/remote/fix-title') {
|
||
return { size: 0, type: 'directory', mtime: 0 }
|
||
}
|
||
const error = new Error('missing') as Error & { code: string }
|
||
error.code = 'ENOENT'
|
||
throw error
|
||
}),
|
||
readFile: vi.fn().mockRejectedValue(new Error('missing'))
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'fix-title',
|
||
baseBranch: 'abc123',
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'feature/fix',
|
||
'/remote/fix-title-2',
|
||
{ checkoutExistingBranch: true }
|
||
)
|
||
expect(mux.request).toHaveBeenCalledWith('session.registerRoot', {
|
||
rootPath: '/remote/fix-title-2'
|
||
})
|
||
})
|
||
|
||
it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'branch' && args.includes('feature/something')) {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'for-each-ref') {
|
||
return { stdout: 'refs/remotes/origin/feature/something\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) {
|
||
throw new Error('missing local branch')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) {
|
||
throw new Error('missing local branch')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/feature-something-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/something-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'feature-something',
|
||
branchNameOverride: 'feature/something'
|
||
})
|
||
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'feature/something-2',
|
||
'/remote/feature-something-2',
|
||
{ base: 'origin/main' }
|
||
)
|
||
})
|
||
|
||
it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\nfoo/bar\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'for-each-ref') {
|
||
return { stdout: 'refs/remotes/foo/bar/feature/something\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) {
|
||
throw new Error('missing local branch')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) {
|
||
throw new Error('missing local branch')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/feature-something-2',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature/something-2',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'feature-something',
|
||
branchNameOverride: 'feature/something'
|
||
})
|
||
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'feature/something-2',
|
||
'/remote/feature-something-2',
|
||
{ base: 'origin/main' }
|
||
)
|
||
})
|
||
|
||
it('unsets SSH branch base config before removing a sparse worktree after setup failure', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const setupError = new Error('sparse init failed')
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'sparse-checkout' && args[1] === 'init') {
|
||
throw setupError
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn()
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getSparsePresets.mockReturnValue([])
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'sparse-dashboard',
|
||
sparseCheckout: {
|
||
directories: ['apps/mobile']
|
||
}
|
||
})
|
||
).rejects.toThrow('sparse init failed')
|
||
|
||
expect(provider.exec).toHaveBeenCalledWith(
|
||
['config', '--local', '--unset-all', 'branch.sparse-dashboard.base'],
|
||
'/remote/sparse-dashboard'
|
||
)
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/sparse-dashboard', true, {
|
||
deleteBranch: true,
|
||
forceBranchDelete: true
|
||
})
|
||
})
|
||
|
||
it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'fetch') {
|
||
throw new Error('network unavailable')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
|
||
addWorktree: vi.fn(),
|
||
listWorktrees: vi.fn()
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard',
|
||
baseBranch: 'origin/master'
|
||
})
|
||
).rejects.toThrow(
|
||
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
|
||
)
|
||
|
||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'origin',
|
||
'master',
|
||
'refs/remotes/origin/master',
|
||
{ skipAutoMaintenance: true }
|
||
)
|
||
})
|
||
|
||
it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => {
|
||
// Regression: a stale persisted repo base must fall back to the detected
|
||
// primary default instead of blocking creation.
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/master'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'symbolic-ref') {
|
||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled()
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'origin',
|
||
'main',
|
||
'refs/remotes/origin/main',
|
||
{ skipAutoMaintenance: true }
|
||
)
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'improve-dashboard',
|
||
'/remote/improve-dashboard',
|
||
{
|
||
base: 'origin/main'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('keeps a usable SSH persisted local branch base after registering the repo root', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'develop'
|
||
}
|
||
let repoRootRegistered = false
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'config') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'symbolic-ref') {
|
||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
|
||
return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/local-branch-base',
|
||
head: 'develop-sha',
|
||
branch: 'refs/heads/local-branch-base',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi
|
||
.fn()
|
||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||
if (payload.rootPath === repo.path) {
|
||
repoRootRegistered = true
|
||
}
|
||
}),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'local-branch-base'
|
||
})
|
||
|
||
expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path })
|
||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'local-branch-base',
|
||
'/remote/local-branch-base',
|
||
{
|
||
base: 'develop'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'team/feature'
|
||
}
|
||
let repoRootRegistered = false
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'config') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'team\norigin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'symbolic-ref') {
|
||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
|
||
return { stdout: 'main-sha\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||
return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/slash-local-base',
|
||
head: 'team-feature-sha',
|
||
branch: 'refs/heads/slash-local-base',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi
|
||
.fn()
|
||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||
if (payload.rootPath === repo.path) {
|
||
repoRootRegistered = true
|
||
}
|
||
}),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'slash-local-base'
|
||
})
|
||
|
||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'team',
|
||
'feature',
|
||
'refs/remotes/team/feature'
|
||
)
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'slash-local-base',
|
||
'/remote/slash-local-base',
|
||
{
|
||
base: 'team/feature'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/first-worktree',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/first-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
.mockResolvedValueOnce([
|
||
{
|
||
path: '/remote/second-worktree',
|
||
head: 'def456',
|
||
branch: 'refs/heads/second-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'first-worktree'
|
||
})
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'second-worktree'
|
||
})
|
||
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'origin',
|
||
'main',
|
||
'refs/remotes/origin/main',
|
||
{ skipAutoMaintenance: true }
|
||
)
|
||
expect(provider.addWorktree).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
it('skips broad SSH remote fetch for an existing commit SHA base', async () => {
|
||
const sha = 'c'.repeat(40)
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'for-each-ref') {
|
||
return { stdout: '', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||
throw new Error('missing local branch')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes(`${sha}^{commit}`)) {
|
||
return { stdout: `${sha}\n`, stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/fix-title',
|
||
head: sha,
|
||
branch: 'refs/heads/feature/fix',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'fix-title',
|
||
baseBranch: sha,
|
||
branchNameOverride: 'feature/fix'
|
||
})
|
||
|
||
expect(provider.exec).not.toHaveBeenCalledWith(['fetch', 'origin'], '/remote/repo')
|
||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'feature/fix',
|
||
'/remote/fix-title',
|
||
{ base: sha }
|
||
)
|
||
})
|
||
|
||
it('shares an in-flight SSH create-base prefetch with create', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
let resolveFetch!: () => void
|
||
const pendingFetch = new Promise<void>((resolve) => {
|
||
resolveFetch = resolve
|
||
})
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockReturnValue(pendingFetch),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/prefetched-worktree',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/prefetched-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const prefetch = handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-ssh'
|
||
}) as Promise<void>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
|
||
const create = handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'prefetched-worktree'
|
||
}) as Promise<unknown>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||
|
||
resolveFetch()
|
||
await prefetch
|
||
await create
|
||
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('registers the SSH repo root before create-base prefetch probes refs', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/master'
|
||
}
|
||
const registeredRoots = new Set<string>()
|
||
const events: string[] = []
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`)
|
||
if (!registeredRoots.has('/remote/repo')) {
|
||
throw new Error('root not registered')
|
||
}
|
||
if (args[0] === 'symbolic-ref') {
|
||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
|
||
throw new Error('missing stale base')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||
return { stdout: 'main-sha\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/prefetched-worktree',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/prefetched-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi
|
||
.fn()
|
||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||
events.push(`register:${payload.rootPath}`)
|
||
registeredRoots.add(payload.rootPath)
|
||
}),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-ssh'
|
||
})
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'prefetched-worktree'
|
||
})
|
||
|
||
expect(events[0]).toBe('register:/remote/repo')
|
||
expect(events).not.toContain('exec:symbolic-ref:false')
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'prefetched-worktree',
|
||
'/remote/prefetched-worktree',
|
||
{
|
||
base: 'origin/main'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('does not let SSH prefetch turn a persisted slash-named local branch into a remote-tracking base', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'team/feature'
|
||
}
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'symbolic-ref') {
|
||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'team\norigin\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
|
||
return { stdout: 'main-sha\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||
throw new Error('missing remote-tracking ref')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||
return { stdout: 'team-feature-sha\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/slash-local-base',
|
||
head: 'team-feature-sha',
|
||
branch: 'refs/heads/slash-local-base',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-ssh'
|
||
})
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'slash-local-base'
|
||
})
|
||
|
||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'team',
|
||
'feature',
|
||
'refs/remotes/team/feature'
|
||
)
|
||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'slash-local-base',
|
||
'/remote/slash-local-base',
|
||
{
|
||
base: 'team/feature'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('shares in-flight SSH create-base resolution with create', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
let resolveRemoteList!: () => void
|
||
const pendingRemoteList = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||
resolveRemoteList = () => resolve({ stdout: 'origin\n', stderr: '' })
|
||
})
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return pendingRemoteList
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/prefetched-worktree',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/prefetched-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const prefetch = handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-ssh'
|
||
}) as Promise<void>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.exec.mock.calls.filter(([args]) => args[0] === 'remote')).toHaveLength(1)
|
||
|
||
const create = handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'prefetched-worktree'
|
||
}) as Promise<unknown>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.exec.mock.calls.filter(([args]) => args[0] === 'remote')).toHaveLength(1)
|
||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
|
||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||
|
||
resolveRemoteList()
|
||
await prefetch
|
||
await create
|
||
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('queues different SSH create-base fetch shapes on the same remote', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: 'origin/main'
|
||
}
|
||
let resolveExactFetch!: () => void
|
||
const pendingExactFetch = new Promise<void>((resolve) => {
|
||
resolveExactFetch = resolve
|
||
})
|
||
const provider = {
|
||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'remote') {
|
||
return { stdout: 'origin\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
}),
|
||
fetchRemoteTrackingRef: vi.fn().mockReturnValue(pendingExactFetch),
|
||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/local-base-worktree',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/local-base-worktree',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
const mux = {
|
||
request: vi.fn().mockResolvedValue(undefined),
|
||
notify: vi.fn()
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||
|
||
const prefetch = handlers['worktrees:prefetchCreateBase'](null, {
|
||
repoId: 'repo-ssh'
|
||
}) as Promise<void>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledTimes(1)
|
||
|
||
const create = handlers['worktrees:create'](null, {
|
||
repoId: 'repo-ssh',
|
||
name: 'local-base-worktree',
|
||
baseBranch: 'local-base'
|
||
}) as Promise<unknown>
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
expect(provider.exec.mock.calls.filter(([args]) => args[0] === 'fetch')).toHaveLength(0)
|
||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||
|
||
resolveExactFetch()
|
||
await vi.waitFor(() =>
|
||
expect(provider.exec.mock.calls.filter(([args]) => args[0] === 'fetch')).toHaveLength(1)
|
||
)
|
||
await prefetch
|
||
await create
|
||
|
||
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/live',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/live',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
},
|
||
{
|
||
path: '/remote/live-child',
|
||
head: 'def456',
|
||
branch: 'refs/heads/live-child',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
store.getAllWorktreeLineage.mockReturnValue({
|
||
'repo-ssh::/remote/missing-child': {
|
||
parentWorktreeId: 'repo-ssh::/remote/live'
|
||
},
|
||
'repo-ssh::/remote/live-child': {
|
||
parentWorktreeId: 'repo-ssh::/remote/missing-parent',
|
||
parentWorktreeInstanceId: 'old-parent-instance'
|
||
},
|
||
'repo-ssh::/remote/live': {
|
||
parentWorktreeId: 'other-repo::/elsewhere'
|
||
}
|
||
})
|
||
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
|
||
worktreeId === 'repo-ssh::/remote/missing-parent'
|
||
? { instanceId: 'old-parent-instance' }
|
||
: undefined
|
||
)
|
||
|
||
await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||
|
||
expect(store.removeWorktreeLineage).toHaveBeenCalledWith('repo-ssh::/remote/missing-child')
|
||
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live-child')
|
||
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live')
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-ssh::/remote/missing-parent',
|
||
expect.objectContaining({ instanceId: expect.any(String) })
|
||
)
|
||
})
|
||
|
||
it('does not repeatedly rotate already-invalid missing parent metadata', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/live-child',
|
||
head: 'def456',
|
||
branch: 'refs/heads/live-child',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
store.getAllWorktreeLineage.mockReturnValue({
|
||
'repo-ssh::/remote/live-child': {
|
||
parentWorktreeId: 'repo-ssh::/remote/missing-parent',
|
||
parentWorktreeInstanceId: 'old-parent-instance'
|
||
}
|
||
})
|
||
store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' })
|
||
|
||
await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||
|
||
expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(
|
||
'repo-ssh::/remote/missing-parent',
|
||
expect.objectContaining({ instanceId: expect.any(String) })
|
||
)
|
||
})
|
||
|
||
it('awaits a cold refresh before creating from an existing remote-tracking base', async () => {
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
let resolveFetch!: () => void
|
||
const pendingFetch = new Promise<{ ok: true }>((resolve) => {
|
||
resolveFetch = () => resolve({ ok: true })
|
||
})
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockReturnValue(pendingFetch)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (
|
||
args[0] === 'rev-parse' &&
|
||
(args.includes('refs/remotes/origin/master^{commit}') ||
|
||
args.includes('refs/heads/origin/master^{commit}'))
|
||
) {
|
||
throw new Error('missing ref')
|
||
}
|
||
return { stdout: 'created-sha\n', stderr: '' }
|
||
})
|
||
|
||
const createPromise = handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
}) as Promise<unknown>
|
||
|
||
const earlyResult = await Promise.race([
|
||
createPromise.then(() => 'resolved'),
|
||
new Promise((resolve) => setTimeout(() => resolve('pending'), 0))
|
||
])
|
||
expect(earlyResult).toBe('pending')
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
|
||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
remoteBase
|
||
)
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||
resolveFetch()
|
||
const result = (await createPromise) as CreateWorktreeResult
|
||
expect(addWorktreeMock).toHaveBeenCalled()
|
||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({ baseRef: 'refs/remotes/origin/main' })
|
||
)
|
||
})
|
||
|
||
it('creates from the detected default base when the persisted base is stale', async () => {
|
||
// Regression: a stale persisted repo base must fall back to the detected
|
||
// primary default instead of blocking creation.
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: 'origin/master'
|
||
})
|
||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||
baseBranch === 'origin/main' ? remoteBase : null
|
||
)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||
ok: true,
|
||
errorKind: 'git_error'
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (
|
||
args[0] === 'rev-parse' &&
|
||
(args.includes('refs/remotes/origin/master^{commit}') ||
|
||
args.includes('refs/heads/origin/master^{commit}'))
|
||
) {
|
||
throw new Error('missing ref')
|
||
}
|
||
return { stdout: 'created-sha\n', stderr: '' }
|
||
})
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalled()
|
||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'origin/master'
|
||
)
|
||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'origin/main'
|
||
)
|
||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
remoteBase
|
||
)
|
||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||
})
|
||
|
||
it('keeps a usable persisted local branch base when a detected default exists', async () => {
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: 'develop'
|
||
})
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||
ok: false,
|
||
errorKind: 'git_error'
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
|
||
return { stdout: 'develop-sha\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'fetch') {
|
||
throw new Error('network unavailable')
|
||
}
|
||
return { stdout: 'created-sha\n', stderr: '' }
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
'develop',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => {
|
||
const remoteBase = {
|
||
remote: 'team',
|
||
branch: 'feature',
|
||
ref: 'refs/remotes/team/feature',
|
||
base: 'team/feature'
|
||
}
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: 'team/feature'
|
||
})
|
||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||
baseBranch === 'team/feature' ? remoteBase : null
|
||
)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/slash-local-base',
|
||
head: 'created-sha',
|
||
branch: 'slash-local-base',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||
throw new Error('missing remote-tracking ref')
|
||
}
|
||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||
return { stdout: 'team-feature-sha\n', stderr: '' }
|
||
}
|
||
if (args[0] === 'fetch') {
|
||
throw new Error('network unavailable')
|
||
}
|
||
return { stdout: 'created-sha\n', stderr: '' }
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'slash-local-base'
|
||
})
|
||
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
|
||
|
||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/slash-local-base',
|
||
'slash-local-base',
|
||
'team/feature',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('keeps an explicit base strict when the pre-create refresh fails', async () => {
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'master',
|
||
ref: 'refs/remotes/origin/master',
|
||
base: 'origin/master'
|
||
}
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||
ok: false,
|
||
errorKind: 'git_error'
|
||
})
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: 'origin/main'
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
baseBranch: 'origin/master'
|
||
})
|
||
).rejects.toThrow(
|
||
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
|
||
)
|
||
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('delegates remote-tracking base freshness to the runtime before create', async () => {
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
remoteBase
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
|
||
})
|
||
)
|
||
})
|
||
|
||
it('threads the local base update suggestion from local create results', async () => {
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
addWorktreeMock.mockResolvedValue({
|
||
localBaseRefUpdateSuggestion: {
|
||
baseRef: 'origin/main',
|
||
localBranch: 'main',
|
||
behind: 2
|
||
}
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||
|
||
const result = (await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})) as CreateWorktreeResult
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
'origin/main',
|
||
false,
|
||
false,
|
||
{
|
||
suggestLocalBaseRefUpdate: true,
|
||
remoteTrackingBase: {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
}
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({ baseRef: 'refs/remotes/origin/main' })
|
||
)
|
||
expect(result.localBaseRefUpdateSuggestion).toEqual({
|
||
baseRef: 'origin/main',
|
||
localBranch: 'main',
|
||
behind: 2
|
||
})
|
||
})
|
||
|
||
it('throws a clear error when no default base ref can be resolved', async () => {
|
||
// Why: guard against regressing to a silent 'origin/main' fallback. When
|
||
// default-base resolution returns null (e.g. a fresh repo with no origin/HEAD,
|
||
// no origin/main, no origin/master, and no local main/master), we must
|
||
// fail loudly with a message that prompts the user to pick a base
|
||
// branch, not hand a non-existent ref to `git worktree add`.
|
||
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null)
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
).rejects.toThrow(/Could not resolve a default base ref/)
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('creates an issue-command runner for an existing repo/worktree pair', async () => {
|
||
const result = await handlers['hooks:createIssueCommandRunner'](null, {
|
||
repoId: 'repo-1',
|
||
worktreePath: '/workspace/improve-dashboard',
|
||
command: 'codex exec "long command"'
|
||
})
|
||
|
||
expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: 'repo-1' }),
|
||
'/workspace/improve-dashboard',
|
||
'codex exec "long command"',
|
||
{}
|
||
)
|
||
expect(result).toMatchObject({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
|
||
envVars: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
}
|
||
})
|
||
})
|
||
|
||
it('lists a synthetic worktree for folder-mode repos', async () => {
|
||
const rootWorktreeId = 'repo-1::/workspace/folder'
|
||
const priorWorktreeIds = ['repo-1::/workspace/old-folder']
|
||
const rootMeta = makeWorktreeMeta({
|
||
instanceId: 'folder-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
priorWorktreeIds
|
||
})
|
||
store.getRepos.mockReturnValue([
|
||
{
|
||
id: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
}
|
||
])
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
})
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
[rootWorktreeId]: rootMeta
|
||
})
|
||
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
|
||
worktreeId === rootWorktreeId ? rootMeta : undefined
|
||
)
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: rootWorktreeId,
|
||
repoId: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
branch: '',
|
||
head: '',
|
||
isMainWorktree: true,
|
||
priorWorktreeIds
|
||
})
|
||
])
|
||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('returns reconstructed rows when an SSH provider is unavailable', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({
|
||
displayName: 'Feature workspace',
|
||
comment: 'persisted comment',
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'LIN-123',
|
||
isArchived: true,
|
||
isUnread: true,
|
||
isPinned: true,
|
||
sortOrder: 7,
|
||
lastActivityAt: 42,
|
||
workspaceStatus: 'blocked',
|
||
diffComments: [
|
||
{
|
||
id: 'comment-1',
|
||
worktreeId: 'repo-ssh::/remote/feature-wt',
|
||
filePath: 'src/app.ts',
|
||
lineNumber: 10,
|
||
body: 'check this',
|
||
createdAt: 1,
|
||
updatedAt: 1
|
||
}
|
||
],
|
||
sparseDirectories: ['packages/web'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
})
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: 'repo-ssh::/remote/feature-wt',
|
||
repoId: 'repo-ssh',
|
||
path: '/remote/feature-wt',
|
||
head: '',
|
||
branch: '',
|
||
isBare: false,
|
||
isMainWorktree: false,
|
||
isSparse: true,
|
||
displayName: 'Feature workspace',
|
||
comment: 'persisted comment',
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'LIN-123',
|
||
isArchived: true,
|
||
isUnread: true,
|
||
isPinned: true,
|
||
sortOrder: 7,
|
||
lastActivityAt: 42,
|
||
workspaceStatus: 'blocked',
|
||
sparseDirectories: ['packages/web'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: 'preset-1',
|
||
diffComments: [
|
||
expect.objectContaining({
|
||
id: 'comment-1',
|
||
filePath: 'src/app.ts'
|
||
})
|
||
]
|
||
})
|
||
])
|
||
expect(store.getWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', {
|
||
projectId: 'repo:repo-ssh',
|
||
hostId: 'ssh:conn-1',
|
||
projectHostSetupId: 'repo-ssh'
|
||
})
|
||
})
|
||
|
||
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockRejectedValue(new Error('connection lost'))
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({
|
||
displayName: 'Feature workspace',
|
||
lastActivityAt: 42
|
||
})
|
||
})
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||
|
||
expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: 'repo-ssh::/remote/feature-wt',
|
||
displayName: 'Feature workspace',
|
||
lastActivityAt: 42
|
||
})
|
||
])
|
||
})
|
||
|
||
it('keeps local listing failure behavior as an empty list', async () => {
|
||
listWorktreesMock.mockRejectedValue(new Error('filesystem denied'))
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({
|
||
displayName: 'Should not appear'
|
||
})
|
||
})
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(listed).toEqual([])
|
||
expect(store.getAllWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('ignores malformed metadata keys during SSH fallback', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'not-a-worktree-id': makeWorktreeMeta({ displayName: 'Bad row' }),
|
||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Good row' })
|
||
})
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: 'repo-ssh::/remote/feature-wt',
|
||
displayName: 'Good row'
|
||
})
|
||
])
|
||
})
|
||
|
||
it('does not use the repo display name for sparse fallback rows with empty branches', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::/remote/custom-name': makeWorktreeMeta({
|
||
sparseDirectories: ['packages/web']
|
||
})
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||
displayName: string
|
||
isSparse?: boolean
|
||
sparseDirectories?: string[]
|
||
}[]
|
||
|
||
expect(listed[0]).toMatchObject({
|
||
displayName: 'custom-name',
|
||
isSparse: true,
|
||
sparseDirectories: ['packages/web']
|
||
})
|
||
})
|
||
|
||
it('uses path equivalence to mark the reconstructed SSH main worktree', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: 'C:\\Remote\\Repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::c:/remote/repo': makeWorktreeMeta()
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||
isMainWorktree: boolean
|
||
}[]
|
||
|
||
expect(listed[0].isMainWorktree).toBe(true)
|
||
})
|
||
|
||
it('includes SSH fallback rows in listAll alongside healthy local rows', async () => {
|
||
const sshRepo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'SSH Repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
const localRepo = {
|
||
id: 'repo-local',
|
||
path: '/workspace/local',
|
||
displayName: 'Local Repo',
|
||
badgeColor: '#111',
|
||
addedAt: 0
|
||
}
|
||
store.getRepos.mockReturnValue([sshRepo, localRepo])
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Remote cached' })
|
||
})
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/local',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
])
|
||
|
||
const listed = await handlers['worktrees:listAll'](null, undefined)
|
||
|
||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||
expect(listed).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
id: 'repo-ssh::/remote/feature-wt',
|
||
displayName: 'Remote cached'
|
||
}),
|
||
expect.objectContaining({
|
||
id: 'repo-local::/workspace/local',
|
||
branch: 'refs/heads/main'
|
||
})
|
||
])
|
||
)
|
||
})
|
||
|
||
it('snapshots SSH fallback metadata once for listAll', async () => {
|
||
const sshRepoA = {
|
||
id: 'repo-ssh-a',
|
||
path: '/remote/a',
|
||
displayName: 'SSH A',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1'
|
||
}
|
||
const sshRepoB = {
|
||
id: 'repo-ssh-b',
|
||
path: '/remote/b',
|
||
displayName: 'SSH B',
|
||
badgeColor: '#111',
|
||
addedAt: 0,
|
||
connectionId: 'conn-2'
|
||
}
|
||
store.getRepos.mockReturnValue([sshRepoA, sshRepoB])
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh-a::/remote/a/one': makeWorktreeMeta({ displayName: 'One' }),
|
||
'repo-ssh-b::/remote/b/two': makeWorktreeMeta({ displayName: 'Two' })
|
||
})
|
||
|
||
const listed = await handlers['worktrees:listAll'](null, undefined)
|
||
|
||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }),
|
||
expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' })
|
||
])
|
||
})
|
||
|
||
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
|
||
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
|
||
// (e.g. a folder repo just added, or a pre-existing worktree in a
|
||
// newly-added git repo) would otherwise fall back to `lastActivityAt: 0`
|
||
// and rank dead last in the Recent sort.
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/discovered-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||
const stampedMeta = {
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 1_700_000_000_000
|
||
}
|
||
store.setWorktreeMeta.mockReturnValue(stampedMeta)
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||
id: string
|
||
lastActivityAt: number
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/discovered-wt',
|
||
expect.objectContaining({
|
||
lastActivityAt: expect.any(Number),
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
)
|
||
expect(listed[0]).toMatchObject({
|
||
id: 'repo-1::/workspace/discovered-wt',
|
||
lastActivityAt: 1_700_000_000_000
|
||
})
|
||
})
|
||
|
||
it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => {
|
||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||
// would overwrite real activity and reshuffle the sidebar on refresh. Host
|
||
// ownership can still be filled because it is derived from the repo setup.
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/existing-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue({
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
instanceId: 'existing-instance',
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 42
|
||
})
|
||
store.setWorktreeMeta.mockReturnValue({
|
||
instanceId: 'existing-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 42
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||
id: string
|
||
lastActivityAt: number
|
||
projectId?: string
|
||
hostId?: string
|
||
projectHostSetupId?: string
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
expect(listed[0].lastActivityAt).toBe(42)
|
||
expect(listed[0]).toMatchObject({
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('repairs legacy project ids when discovery now resolves the same host setup to a logical project', async () => {
|
||
// Why: provider identity can become available after metadata was written.
|
||
// Existing workspaces should move from repo-scoped IDs to the logical
|
||
// project ID without losing activity ordering.
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/existing-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getProjectHostSetups.mockReturnValue([
|
||
{
|
||
id: 'repo-1',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'local',
|
||
repoId: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
setupState: 'ready',
|
||
setupMethod: 'legacy-repo',
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue({
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
instanceId: 'existing-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 42
|
||
})
|
||
store.setWorktreeMeta.mockReturnValue({
|
||
instanceId: 'existing-instance',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 42
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||
id: string
|
||
lastActivityAt: number
|
||
projectId?: string
|
||
hostId?: string
|
||
projectHostSetupId?: string
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||
projectId: 'github:stablyai/orca'
|
||
})
|
||
expect(listed[0]).toMatchObject({
|
||
id: 'repo-1::/workspace/existing-wt',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 42
|
||
})
|
||
})
|
||
|
||
it('does not repair ownership when discovery points at a different project-host setup', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/existing-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getProjectHostSetups.mockReturnValue([
|
||
{
|
||
id: 'repo-1',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'local',
|
||
repoId: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
setupState: 'ready',
|
||
setupMethod: 'legacy-repo',
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue({
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
instanceId: 'existing-instance',
|
||
projectId: 'github:other/project',
|
||
hostId: 'ssh:ssh-target-1',
|
||
projectHostSetupId: 'repo-other-host',
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 42
|
||
})
|
||
|
||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('repairs legacy project ids when SSH worktree listing falls back to persisted metadata', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/orca',
|
||
displayName: 'orca',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'ssh-target-1'
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-ssh::/remote/orca': makeWorktreeMeta({
|
||
instanceId: 'existing-instance',
|
||
projectId: 'repo:repo-ssh',
|
||
hostId: 'ssh:ssh-target-1',
|
||
projectHostSetupId: 'repo-ssh',
|
||
lastActivityAt: 42
|
||
})
|
||
})
|
||
store.getProjectHostSetups.mockReturnValue([
|
||
{
|
||
id: 'repo-ssh',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'ssh:ssh-target-1',
|
||
repoId: 'repo-ssh',
|
||
path: '/remote/orca',
|
||
displayName: 'orca',
|
||
setupState: 'ready',
|
||
setupMethod: 'imported-existing-folder',
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({
|
||
instanceId: 'existing-instance',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'ssh:ssh-target-1',
|
||
projectHostSetupId: 'repo-ssh',
|
||
lastActivityAt: 42
|
||
})
|
||
)
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||
id: string
|
||
projectId?: string
|
||
hostId?: string
|
||
projectHostSetupId?: string
|
||
lastActivityAt: number
|
||
}[]
|
||
|
||
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1')
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', {
|
||
projectId: 'github:stablyai/orca'
|
||
})
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: 'repo-ssh::/remote/orca',
|
||
projectId: 'github:stablyai/orca',
|
||
hostId: 'ssh:ssh-target-1',
|
||
projectHostSetupId: 'repo-ssh',
|
||
lastActivityAt: 42
|
||
})
|
||
])
|
||
})
|
||
|
||
it('does not rewrite discovery metadata when instance and project-host ownership already exist', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/existing-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue({
|
||
instanceId: 'existing-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 42
|
||
})
|
||
|
||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('backfills instanceId on discovery for persisted metadata from older profiles', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/existing-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue({
|
||
displayName: '',
|
||
comment: '',
|
||
linkedIssue: null,
|
||
linkedPR: null,
|
||
isArchived: false,
|
||
isUnread: false,
|
||
isPinned: false,
|
||
sortOrder: 0,
|
||
lastActivityAt: 42
|
||
})
|
||
store.setWorktreeMeta.mockReturnValue({
|
||
instanceId: 'new-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 42
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||
id: string
|
||
instanceId?: string
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/existing-wt',
|
||
expect.objectContaining({
|
||
instanceId: expect.any(String),
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
)
|
||
expect(listed[0]).toMatchObject({
|
||
instanceId: 'new-instance',
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
|
||
// Why: folder repos produce a synthetic worktree that flows through the
|
||
// same list path. Without the stamp, adding a folder puts its card at the
|
||
// bottom of Recent even though the user just added it.
|
||
store.getRepos.mockReturnValue([
|
||
{
|
||
id: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
}
|
||
])
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
})
|
||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||
store.setWorktreeMeta.mockReturnValue({
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1',
|
||
lastActivityAt: 1_700_000_000_000
|
||
})
|
||
|
||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/folder',
|
||
expect.objectContaining({
|
||
lastActivityAt: expect.any(Number),
|
||
projectId: 'repo:repo-1',
|
||
hostId: 'local',
|
||
projectHostSetupId: 'repo-1'
|
||
})
|
||
)
|
||
})
|
||
|
||
it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => {
|
||
// Why: the stamping logic lives in both worktrees:list and worktrees:listAll.
|
||
// Without a dedicated test, a regression in the listAll loop would silently
|
||
// bury newly-discovered worktrees from the multi-repo sidebar view.
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/discovered-wt',
|
||
head: 'abc123',
|
||
branch: 'refs/heads/feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||
|
||
const listed = (await handlers['worktrees:listAll'](null, undefined)) as {
|
||
id: string
|
||
lastActivityAt: number
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/discovered-wt',
|
||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||
)
|
||
expect(listed[0]).toMatchObject({
|
||
id: 'repo-1::/workspace/discovered-wt',
|
||
lastActivityAt: 1_700_000_000_000
|
||
})
|
||
})
|
||
|
||
it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => {
|
||
const repos = Array.from({ length: 10 }, (_, index) => ({
|
||
id: `repo-${index}`,
|
||
path: `/workspace/repo-${index}`,
|
||
displayName: `repo-${index}`,
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}))
|
||
store.getRepos.mockReturnValue(repos)
|
||
let activeScans = 0
|
||
let maxActiveScans = 0
|
||
let notifyScanStarted: (() => void) | undefined
|
||
const waitForScanCount = async (count: number): Promise<void> => {
|
||
while (listWorktreesMock.mock.calls.length < count) {
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timeout = setTimeout(
|
||
() => reject(new Error(`Timed out waiting for ${count} scans`)),
|
||
1000
|
||
)
|
||
notifyScanStarted = () => {
|
||
clearTimeout(timeout)
|
||
resolve()
|
||
}
|
||
})
|
||
}
|
||
}
|
||
const pendingScans: (() => void)[] = []
|
||
listWorktreesMock.mockImplementation(
|
||
async (
|
||
repoPath: string
|
||
): Promise<
|
||
{ path: string; head: string; branch: string; isBare: false; isMainWorktree: true }[]
|
||
> => {
|
||
activeScans += 1
|
||
maxActiveScans = Math.max(maxActiveScans, activeScans)
|
||
await new Promise<void>((resolve) => {
|
||
pendingScans.push(resolve)
|
||
notifyScanStarted?.()
|
||
notifyScanStarted = undefined
|
||
})
|
||
activeScans -= 1
|
||
return [
|
||
{
|
||
path: repoPath,
|
||
head: 'abc123',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]
|
||
}
|
||
)
|
||
|
||
const listPromise = handlers['worktrees:listAll'](null, undefined) as Promise<
|
||
{ path: string }[]
|
||
>
|
||
await Promise.resolve()
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(8)
|
||
expect(maxActiveScans).toBe(8)
|
||
|
||
for (const resolve of pendingScans.splice(0)) {
|
||
resolve()
|
||
}
|
||
await waitForScanCount(10)
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledTimes(10)
|
||
|
||
for (const resolve of pendingScans.splice(0)) {
|
||
resolve()
|
||
}
|
||
const listed = await listPromise
|
||
|
||
expect(maxActiveScans).toBe(8)
|
||
expect(listed.map((worktree) => worktree.path)).toEqual(repos.map((repo) => repo.path))
|
||
})
|
||
|
||
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
|
||
// Why: `gh pr list` is network-bound and previously fired on every single
|
||
// create, adding 1–3s to the happy path. We now only probe PR conflicts
|
||
// from suffix=2 onward — once a local/remote branch collision has already
|
||
// forced us past the first candidate and uniqueness matters enough to
|
||
// justify the GitHub round-trip. This test covers that delayed path:
|
||
// suffix=1 is a branch conflict, suffix=2 is owned by an old PR, so the
|
||
// loop lands on suffix=3.
|
||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'improve-dashboard' ? 'remote' : null
|
||
)
|
||
getPRForBranchMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||
branch === 'improve-dashboard-2'
|
||
? {
|
||
number: 3127,
|
||
title: 'Existing PR',
|
||
state: 'merged',
|
||
url: 'https://example.com/pr/3127',
|
||
checksStatus: 'success',
|
||
updatedAt: '2026-04-01T00:00:00Z',
|
||
mergeable: 'UNKNOWN'
|
||
}
|
||
: null
|
||
)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard-3',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard-3',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard-3',
|
||
'improve-dashboard-3',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/improve-dashboard-3',
|
||
branch: 'improve-dashboard-3'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('does not call `gh pr list` on the happy path (no branch conflict)', async () => {
|
||
// Why: guards the speed optimization. If a future refactor accidentally
|
||
// reintroduces the PR probe on the first iteration, the happy path will
|
||
// silently regain a 1–3s GitHub round-trip per click; this test fails
|
||
// loudly instead.
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
|
||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
const createdWorktreeList = [
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'abc123',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]
|
||
|
||
it('returns a setup launch payload when setup should run', async () => {
|
||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup'
|
||
}
|
||
})
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
setupDecision: 'run'
|
||
})
|
||
|
||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: 'repo-1' }),
|
||
'/workspace/improve-dashboard',
|
||
'pnpm worktree:setup'
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
repoId: 'repo-1',
|
||
path: '/workspace/improve-dashboard',
|
||
branch: 'improve-dashboard'
|
||
}),
|
||
setup: {
|
||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||
envVars: {
|
||
ORCA_ROOT_PATH: '/workspace/repo',
|
||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||
}
|
||
}
|
||
})
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
'origin/main',
|
||
false
|
||
)
|
||
})
|
||
|
||
it('routes setup runner generation through the selected WSL project runtime', async () => {
|
||
setPlatform('win32')
|
||
store.getProjects.mockReturnValue([
|
||
{
|
||
id: 'project-1',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
sourceRepoIds: ['repo-1'],
|
||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
}
|
||
])
|
||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup'
|
||
}
|
||
})
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup'
|
||
}
|
||
})
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
setupDecision: 'run'
|
||
})
|
||
|
||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: 'repo-1' }),
|
||
'/workspace/improve-dashboard',
|
||
'pnpm worktree:setup',
|
||
{ wslDistro: 'Ubuntu' }
|
||
)
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
'origin/main',
|
||
false,
|
||
false,
|
||
{ wslDistro: 'Ubuntu' }
|
||
)
|
||
})
|
||
|
||
it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => {
|
||
// Why: regression for a silent skip introduced by the #1280 content-equality
|
||
// gate. Benign divergence (whitespace, comments, or any setup edit that
|
||
// landed on the base branch but not yet in the primary checkout) must not
|
||
// disable setup — repo-level trust already gates execution.
|
||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||
getEffectiveHooksMock.mockImplementation((_repo, worktreePath?: string) => ({
|
||
scripts: {
|
||
setup: worktreePath ? 'pnpm worktree:setup # worktree' : 'pnpm worktree:setup'
|
||
}
|
||
}))
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup # worktree'
|
||
}
|
||
})
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
setupDecision: 'run'
|
||
})
|
||
|
||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: 'repo-1' }),
|
||
'/workspace/improve-dashboard',
|
||
'pnpm worktree:setup # worktree'
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
setup: expect.objectContaining({
|
||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh'
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('creates a sparse worktree and persists its sparse metadata', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
...createdWorktreeList[0],
|
||
isSparse: true
|
||
}
|
||
])
|
||
store.setWorktreeMeta.mockReturnValue({
|
||
sparseDirectories: ['packages/web', 'apps/api'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
store.getSparsePresets.mockReturnValue([
|
||
{
|
||
id: 'preset-1',
|
||
repoId: 'repo-1',
|
||
name: 'Frontend and API',
|
||
directories: ['packages/web', 'apps/api'],
|
||
createdAt: 1,
|
||
updatedAt: 1
|
||
}
|
||
])
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
sparseCheckout: {
|
||
directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
|
||
presetId: 'preset-1'
|
||
}
|
||
})
|
||
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
expect(addSparseWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard',
|
||
'improve-dashboard',
|
||
['packages/web', 'apps/api'],
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
sparseDirectories: ['packages/web', 'apps/api'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
)
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
repoId: 'repo-1',
|
||
path: '/workspace/improve-dashboard',
|
||
sparseDirectories: ['packages/web', 'apps/api'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: 'preset-1'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('clears sparse preset attribution when the preset id does not belong to the repo', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
...createdWorktreeList[0],
|
||
isSparse: true
|
||
}
|
||
])
|
||
store.getSparsePresets.mockReturnValue([
|
||
{
|
||
id: 'preset-2',
|
||
repoId: 'repo-1',
|
||
name: 'Other preset',
|
||
directories: ['packages/web'],
|
||
createdAt: 1,
|
||
updatedAt: 1
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
sparseCheckout: {
|
||
directories: ['packages/web'],
|
||
presetId: 'preset-1'
|
||
}
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
sparseDirectories: ['packages/web'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: undefined
|
||
})
|
||
)
|
||
})
|
||
|
||
it('clears sparse preset attribution when normalized directories do not match', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
...createdWorktreeList[0],
|
||
isSparse: true
|
||
}
|
||
])
|
||
store.getSparsePresets.mockReturnValue([
|
||
{
|
||
id: 'preset-1',
|
||
repoId: 'repo-1',
|
||
name: 'Frontend and API',
|
||
directories: ['packages/web', 'apps/api'],
|
||
createdAt: 1,
|
||
updatedAt: 1
|
||
}
|
||
])
|
||
|
||
await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
sparseCheckout: {
|
||
directories: ['packages/web'],
|
||
presetId: 'preset-1'
|
||
}
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
sparseDirectories: ['packages/web'],
|
||
sparseBaseRef: 'origin/main',
|
||
sparsePresetId: undefined
|
||
})
|
||
)
|
||
})
|
||
|
||
it('rejects sparse checkout directories that traverse above the repo root', async () => {
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
sparseCheckout: {
|
||
directories: ['packages/web', '../secrets']
|
||
}
|
||
})
|
||
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
|
||
|
||
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
|
||
'rejects absolute sparse checkout directory before normalization: %s',
|
||
async (directory) => {
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
sparseCheckout: {
|
||
directories: ['packages/web', directory]
|
||
}
|
||
})
|
||
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
|
||
|
||
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
}
|
||
)
|
||
|
||
it('still returns the created worktree when setup runner generation fails', async () => {
|
||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup'
|
||
}
|
||
})
|
||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||
createSetupRunnerScriptMock.mockImplementation(() => {
|
||
throw new Error('disk full')
|
||
})
|
||
|
||
const result = await handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard',
|
||
setupDecision: 'run'
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
worktree: expect.objectContaining({
|
||
repoId: 'repo-1',
|
||
path: '/workspace/improve-dashboard',
|
||
branch: 'improve-dashboard'
|
||
})
|
||
})
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('prunes git worktree tracking when removing an orphaned worktree', async () => {
|
||
mockKnownFeatureWorktree()
|
||
const orphanError = Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: "fatal: '/workspace/feature-wt' is not a working tree"
|
||
})
|
||
removeWorktreeMock.mockRejectedValue(orphanError)
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
// Should have called git worktree prune to clean up stale tracking
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => {
|
||
setPlatform('win32')
|
||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-'))
|
||
const repoPath = join(parentDir, 'repo')
|
||
const worktreePath = join(parentDir, 'feature-wt')
|
||
await mkdir(worktreePath, { recursive: true })
|
||
await writeFile(join(worktreePath, 'scratch.txt'), 'delete me')
|
||
const registeredWorktrees = mockKnownFeatureWorktree(worktreePath, repoPath)
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce(registeredWorktrees)
|
||
.mockResolvedValueOnce(registeredWorktrees)
|
||
.mockResolvedValue([])
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||
const longPathError = Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||
})
|
||
removeWorktreeMock.mockRejectedValue(longPathError)
|
||
const worktreeId = `repo-1::${worktreePath}`
|
||
|
||
try {
|
||
const result = await handlers['worktrees:remove'](null, {
|
||
worktreeId,
|
||
force: true
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
preservedBranch: { branchName: 'feature', head: 'feature' }
|
||
})
|
||
if (ORIGINAL_PLATFORM === 'win32') {
|
||
await expect(lstat(worktreePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||
}
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
} finally {
|
||
await rm(parentDir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not create a preserved-branch target when long-path recovery preserves branch by policy', async () => {
|
||
setPlatform('win32')
|
||
const registeredWorktrees = mockKnownFeatureWorktree()
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce(registeredWorktrees)
|
||
.mockResolvedValueOnce(registeredWorktrees)
|
||
.mockResolvedValue([])
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
|
||
removeWorktreeMock.mockRejectedValue(
|
||
Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||
})
|
||
)
|
||
|
||
const result = await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
force: true
|
||
})
|
||
|
||
expect(result).toEqual({})
|
||
await expect(
|
||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
branchName: 'feature',
|
||
expectedHead: 'feature'
|
||
})
|
||
).rejects.toThrow('No preserved branch cleanup is pending')
|
||
})
|
||
|
||
it('does not recover Windows long-path worktree removal without force', async () => {
|
||
setPlatform('win32')
|
||
mockKnownFeatureWorktree()
|
||
const longPathError = Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||
})
|
||
removeWorktreeMock.mockRejectedValue(longPathError)
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt.')
|
||
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('refuses Windows recovery while Git still reports the row and keeps metadata', async () => {
|
||
setPlatform('win32')
|
||
mockKnownFeatureWorktree()
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||
const removePathSpy = vi
|
||
.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath')
|
||
.mockResolvedValue(undefined)
|
||
removeWorktreeMock.mockRejectedValue(
|
||
Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||
})
|
||
)
|
||
|
||
try {
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
force: true
|
||
})
|
||
).rejects.toThrow(
|
||
'Failed to force delete worktree at /workspace/feature-wt. error: failed to delete deep/file.txt: Filename too long'
|
||
)
|
||
|
||
expect(removePathSpy).not.toHaveBeenCalled()
|
||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||
['worktree', 'prune'],
|
||
expect.anything()
|
||
)
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
} finally {
|
||
removePathSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('retries stale Git registration cleanup after prior local filesystem recovery', async () => {
|
||
setPlatform('win32')
|
||
const missingWorktreePath = 'C:\\workspace\\already-removed'
|
||
const worktreeId = `repo-1::${missingWorktreePath}`
|
||
const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath)
|
||
listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([])
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||
|
||
const result = await handlers['worktrees:remove'](null, {
|
||
worktreeId,
|
||
force: true
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
preservedBranch: { branchName: 'feature', head: 'feature' }
|
||
})
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
})
|
||
|
||
it('preserves a locked missing registration even with force', async () => {
|
||
setPlatform('win32')
|
||
const missingWorktreePath = 'C:\\workspace\\locked-already-removed'
|
||
const worktreeId = `repo-1::${missingWorktreePath}`
|
||
const registeredWorktrees: GitWorktreeInfo[] = [
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: missingWorktreePath,
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false,
|
||
locked: true,
|
||
lockReason: 'active agent session'
|
||
}
|
||
]
|
||
listWorktreesMock.mockResolvedValue(registeredWorktrees)
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||
removeWorktreeMock.mockResolvedValue({})
|
||
|
||
await expect(handlers['worktrees:remove'](null, { worktreeId, force: true })).rejects.toThrow(
|
||
'Worktree is locked by Git. Lock reason: active agent session'
|
||
)
|
||
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('refuses to delete the root workspace for folder-mode repos', async () => {
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-folder',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-folder::/workspace/folder'
|
||
})
|
||
).rejects.toThrow('Cannot delete the project root workspace')
|
||
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('kills PTYs before removing additional folder workspace metadata', async () => {
|
||
const ptyProvider = {} as never
|
||
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-folder',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder'
|
||
})
|
||
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
|
||
|
||
await handlers['worktrees:remove'](null, { worktreeId })
|
||
|
||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||
runtime: runtimeStub,
|
||
localProvider: ptyProvider,
|
||
onPtyStopped: clearProviderPtyStateMock
|
||
})
|
||
expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
store.removeWorktreeMeta.mock.invocationCallOrder[0]
|
||
)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-folder'
|
||
})
|
||
})
|
||
|
||
it('runs the archive hook on remove when skipArchive is not set', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
archive: 'echo archived'
|
||
}
|
||
})
|
||
runHookMock.mockResolvedValue({ success: true, output: '' })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(runHookMock).toHaveBeenCalledWith(
|
||
'archive',
|
||
'/workspace/feature-wt',
|
||
expect.objectContaining({ id: 'repo-1' }),
|
||
undefined,
|
||
{}
|
||
)
|
||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-wt',
|
||
false,
|
||
expect.objectContaining({
|
||
knownRemovedWorktree: expect.objectContaining({
|
||
branch: 'feature',
|
||
head: 'feature',
|
||
path: '/workspace/feature-wt'
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('skips the archive hook on remove when skipArchive is true', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
archive: 'echo archived'
|
||
}
|
||
})
|
||
runHookMock.mockResolvedValue({ success: true, output: '' })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
skipArchive: true
|
||
})
|
||
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-wt',
|
||
false,
|
||
expect.objectContaining({
|
||
knownRemovedWorktree: expect.objectContaining({
|
||
branch: 'feature',
|
||
head: 'feature',
|
||
path: '/workspace/feature-wt'
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('runs the archive hook before removing an SSH worktree', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const callOrder: string[] = []
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('remove')
|
||
}),
|
||
worktreeIsClean: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('preflight')
|
||
return { clean: true }
|
||
}),
|
||
execNonInteractive: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('archive')
|
||
return { stdout: '', stderr: '', exitCode: 0, timedOut: false }
|
||
})
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||
})
|
||
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||
expect(provider.execNonInteractive).toHaveBeenCalledWith(
|
||
'/bin/bash',
|
||
['-lc', 'echo archived'],
|
||
'/remote/feature-wt',
|
||
120_000,
|
||
undefined,
|
||
expect.objectContaining({
|
||
ORCA_ROOT_PATH: '/remote/repo',
|
||
ORCA_WORKTREE_PATH: '/remote/feature-wt'
|
||
})
|
||
)
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||
expect(callOrder).toEqual(['archive', 'preflight', 'remove'])
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('runs SSH archive hooks before failing dirty non-force removal', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const callOrder: string[] = []
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('remove')
|
||
}),
|
||
worktreeIsClean: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('preflight')
|
||
return { clean: false, stdout: ' M src/file.ts\n?? scratch.txt\n' }
|
||
}),
|
||
execNonInteractive: vi.fn().mockImplementation(async () => {
|
||
callOrder.push('archive')
|
||
return { stdout: '', stderr: '', exitCode: 0, timedOut: false }
|
||
})
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||
})
|
||
).rejects.toThrow('Worktree has uncommitted or untracked changes.')
|
||
|
||
expect(callOrder).toEqual(['archive', 'preflight'])
|
||
expect(provider.removeWorktree).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('skips SSH dirty preflight for force removal', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn(),
|
||
execNonInteractive: vi.fn().mockResolvedValue({
|
||
stdout: '',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
timedOut: false
|
||
})
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt',
|
||
force: true
|
||
})
|
||
|
||
expect(provider.worktreeIsClean).not.toHaveBeenCalled()
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true)
|
||
})
|
||
|
||
it('continues SSH worktree removal when the archive hook fails', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
execNonInteractive: vi.fn().mockResolvedValue({
|
||
stdout: '',
|
||
stderr: 'cleanup failed',
|
||
exitCode: 7,
|
||
timedOut: false
|
||
})
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: exit 7\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'exit 7' } })
|
||
|
||
try {
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||
})
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||
'[hooks] archive hook failed for /remote/feature-wt:',
|
||
expect.stringContaining('archive hook exited 7')
|
||
)
|
||
} finally {
|
||
consoleErrorSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('continues SSH worktree removal when archive hook execution rejects', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
execNonInteractive: vi.fn().mockRejectedValue(new Error('relay disconnected'))
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
try {
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||
})
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||
'[hooks] archive hook failed for /remote/feature-wt:',
|
||
'relay disconnected'
|
||
)
|
||
} finally {
|
||
consoleErrorSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('uses cmd.exe for archive hooks on Windows-like SSH worktree paths', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: 'C:\\remote\\repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: 'C:\\remote\\repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: 'C:\\remote\\feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
execNonInteractive: vi.fn().mockResolvedValue({
|
||
stdout: '',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
timedOut: false
|
||
})
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::C:\\remote\\feature-wt'
|
||
})
|
||
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml')
|
||
expect(provider.execNonInteractive).toHaveBeenCalledWith(
|
||
'cmd.exe',
|
||
['/d', '/s', '/c', 'echo archived'],
|
||
'C:\\remote\\feature-wt',
|
||
120_000,
|
||
undefined,
|
||
expect.objectContaining({
|
||
ORCA_ROOT_PATH: 'C:\\remote\\repo',
|
||
ORCA_WORKTREE_PATH: 'C:\\remote\\feature-wt'
|
||
})
|
||
)
|
||
})
|
||
|
||
it('skips the archive hook before removing an SSH worktree when skipArchive is true', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
|
||
execNonInteractive: vi.fn()
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: echo archived\n',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt',
|
||
skipArchive: true
|
||
})
|
||
|
||
expect(provider.execNonInteractive).not.toHaveBeenCalled()
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||
})
|
||
|
||
it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
}
|
||
const sshRepo = {
|
||
...localRepo,
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
connectionId: 'conn-1'
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: sshRepo.path,
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||
}
|
||
store.getRepo.mockReturnValue(localRepo)
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-shared::/remote/feature-wt',
|
||
hostId: 'ssh:conn-1'
|
||
})
|
||
|
||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('fails closed when duplicate repo ids are deleted without a host', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-shared::/remote/feature-wt'
|
||
})
|
||
).rejects.toThrow('Repo not found: repo-shared')
|
||
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('inspects hooks on the requested host when repo ids collide', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||
const fsProvider = {
|
||
readFile: vi.fn().mockResolvedValue({
|
||
content: 'scripts:\n archive: remote-cleanup',
|
||
isBinary: false
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } })
|
||
|
||
await expect(
|
||
handlers['hooks:check'](null, {
|
||
repoId: 'repo-shared',
|
||
hostId: 'ssh:conn-1'
|
||
})
|
||
).resolves.toEqual({
|
||
status: 'ok',
|
||
hasHooks: true,
|
||
hooks: { scripts: { archive: 'remote-cleanup' } },
|
||
mayNeedUpdate: false
|
||
})
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('fails hook inspection closed when duplicate repo ids omit the host', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
|
||
await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({
|
||
status: 'error',
|
||
hasHooks: false,
|
||
hooks: null,
|
||
mayNeedUpdate: false
|
||
})
|
||
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
|
||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not coalesce forget requests for the same id on different hosts', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
let finishFirst!: () => void
|
||
killAllProcessesForWorktreeMock
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
finishFirst = () =>
|
||
resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||
})
|
||
)
|
||
.mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||
|
||
const first = handlers['worktrees:forgetLocal'](null, {
|
||
worktreeId: 'repo-shared::/same/path',
|
||
hostId: 'local'
|
||
}) as Promise<unknown>
|
||
await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1))
|
||
|
||
await expect(
|
||
handlers['worktrees:forgetLocal'](null, {
|
||
worktreeId: 'repo-shared::/same/path',
|
||
hostId: 'ssh:conn-1'
|
||
})
|
||
).resolves.toEqual({})
|
||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2)
|
||
|
||
finishFirst()
|
||
await expect(first).resolves.toEqual({})
|
||
})
|
||
|
||
it('preserves the branch on remove for worktrees created from an existing local branch', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-wt',
|
||
false,
|
||
expect.objectContaining({
|
||
deleteBranch: false,
|
||
knownRemovedWorktree: expect.objectContaining({
|
||
branch: 'feature',
|
||
head: 'feature',
|
||
path: '/workspace/feature-wt'
|
||
})
|
||
})
|
||
)
|
||
})
|
||
|
||
it('force-deletes a branch that was preserved by safe worktree removal', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue({
|
||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
branchName: 'feature/test',
|
||
expectedHead: 'def456'
|
||
})
|
||
|
||
expect(result).toMatchObject({ deleted: true })
|
||
expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'feature/test',
|
||
'def456'
|
||
)
|
||
})
|
||
|
||
it('force-deletes an SSH branch that was preserved by safe worktree removal', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const worktreeId = 'repo-ssh::/remote/feature-wt'
|
||
const provider = {
|
||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: repo.path,
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/remote/feature-wt',
|
||
head: 'def456',
|
||
branch: 'feature/test',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
removeWorktree: vi.fn().mockResolvedValue({
|
||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||
}),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() })
|
||
|
||
await handlers['worktrees:remove'](null, { worktreeId })
|
||
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||
worktreeId,
|
||
branchName: 'feature/test',
|
||
expectedHead: 'def456'
|
||
})
|
||
|
||
expect(result).toMatchObject({ deleted: true })
|
||
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
|
||
'/remote/repo',
|
||
'feature/test',
|
||
'def456'
|
||
)
|
||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rejects stale preserved-branch cleanup actions with an old head', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue({
|
||
preservedBranch: { branchName: 'feature/test', head: 'new456' }
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
branchName: 'feature/test',
|
||
expectedHead: 'old123'
|
||
})
|
||
).rejects.toThrow('No preserved branch cleanup is pending')
|
||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('removes an unused Orca-created fork remote after deleting its worktree', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
const pushTarget = {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'feature/from-fork',
|
||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||
remoteCreated: true
|
||
}
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget })
|
||
})
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'config') {
|
||
throw new Error('no branch config')
|
||
}
|
||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
})
|
||
|
||
it('keeps an Orca-created fork remote while another worktree still uses it', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
const pushTarget = {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'feature/from-fork',
|
||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||
remoteCreated: true
|
||
}
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
|
||
'repo-1::/workspace/other-wt': makeWorktreeMeta({
|
||
pushTarget: {
|
||
...pushTarget,
|
||
branchName: 'other-branch'
|
||
}
|
||
})
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||
['remote', 'remove', 'pr-contributor-orca'],
|
||
expect.any(Object)
|
||
)
|
||
})
|
||
|
||
it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => {
|
||
mockKnownFeatureWorktree()
|
||
removeWorktreeMock.mockResolvedValue(undefined)
|
||
const pushTarget = {
|
||
remoteName: 'pr-contributor-orca',
|
||
branchName: 'feature/from-fork',
|
||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||
remoteCreated: true
|
||
}
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||
store.getAllWorktreeMeta.mockReturnValue({
|
||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
|
||
'repo-2::/workspace/other-wt': makeWorktreeMeta({
|
||
pushTarget: {
|
||
...pushTarget,
|
||
branchName: 'other-branch'
|
||
}
|
||
})
|
||
})
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'config') {
|
||
throw new Error('no branch config')
|
||
}
|
||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
})
|
||
|
||
it('reports already-missing unregistered delete paths before teardown, hooks, or git removal', async () => {
|
||
mockKnownFeatureWorktree('/workspace/real-feature')
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
archive: 'echo archived'
|
||
}
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/not-a-worktree'
|
||
})
|
||
).rejects.toThrow(
|
||
'Worktree is no longer registered with Git and its directory is already gone.'
|
||
)
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('treats forced deletion of an already-missing unregistered worktree as cleanup', async () => {
|
||
mockKnownFeatureWorktree('/workspace/real-feature')
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/already-deleted-wt',
|
||
force: true
|
||
})
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/already-deleted-wt'
|
||
)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/already-deleted-wt')
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/already-deleted-wt'
|
||
)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('cleans up an already-missing unregistered worktree after force recovery', async () => {
|
||
const worktreeId = 'repo-1::/workspace/already-deleted-wt'
|
||
mockKnownFeatureWorktree('/workspace/real-feature')
|
||
|
||
await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow(
|
||
'Worktree is no longer registered with Git and its directory is already gone.'
|
||
)
|
||
|
||
await handlers['worktrees:remove'](null, { worktreeId, force: true })
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('treats normal deletion of an already-missing unregistered worktree as cleanup', async () => {
|
||
mockKnownFeatureWorktree('/workspace/real-feature')
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/already-deleted-wt'
|
||
})
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/already-deleted-wt'
|
||
)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/already-deleted-wt')
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/already-deleted-wt'
|
||
)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
})
|
||
|
||
it('force-removes a legacy Orca-created orphaned worktree directory after Git tracking is gone', async () => {
|
||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-'))
|
||
const repoPath = join(parentDir, 'repo')
|
||
const orphanPath = join(parentDir, 'orphan')
|
||
const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan')
|
||
const worktreeId = `repo-1::${orphanPath}`
|
||
await mkdir(orphanPath, { recursive: true })
|
||
await mkdir(adminWorktreePath, { recursive: true })
|
||
await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`)
|
||
await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`)
|
||
const repo = {
|
||
id: 'repo-1',
|
||
path: repoPath,
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getRepos.mockReturnValue([repo])
|
||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ createdAt: Date.now() }))
|
||
|
||
try {
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId,
|
||
force: true
|
||
})
|
||
|
||
await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
} finally {
|
||
await rm(parentDir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('prompts for force before removing an Orca-created orphaned worktree directory', async () => {
|
||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-'))
|
||
const repoPath = join(parentDir, 'repo')
|
||
const orphanPath = join(parentDir, 'orphan')
|
||
const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan')
|
||
await mkdir(orphanPath, { recursive: true })
|
||
await mkdir(adminWorktreePath, { recursive: true })
|
||
await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`)
|
||
await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`)
|
||
const repo = {
|
||
id: 'repo-1',
|
||
path: repoPath,
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getRepos.mockReturnValue([repo])
|
||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||
store.getWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' })
|
||
)
|
||
|
||
try {
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: `repo-1::${orphanPath}`
|
||
})
|
||
).rejects.toThrow('Worktree is no longer registered with Git but its directory remains.')
|
||
|
||
await expect(lstat(orphanPath)).resolves.toBeTruthy()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
} finally {
|
||
await rm(parentDir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('prompts then force-removes an Orca-created unregistered leftover directory with no git marker', async () => {
|
||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-leftover-'))
|
||
const repoPath = join(parentDir, 'repo')
|
||
const leftoverPath = join(parentDir, 'leftover')
|
||
const worktreeId = `repo-1::${leftoverPath}`
|
||
await mkdir(leftoverPath, { recursive: true })
|
||
await writeFile(join(leftoverPath, 'leftover.txt'), 'kept until force\n')
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: repoPath,
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
})
|
||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||
store.getWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' })
|
||
)
|
||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||
if (args[0] === 'status') {
|
||
throw new Error('fatal: not a git repository')
|
||
}
|
||
return { stdout: '', stderr: '' }
|
||
})
|
||
|
||
try {
|
||
await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow(
|
||
'Worktree is no longer registered with Git but its directory remains.'
|
||
)
|
||
await expect(lstat(leftoverPath)).resolves.toBeTruthy()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, { worktreeId, force: true })
|
||
).resolves.toEqual({})
|
||
|
||
await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
} finally {
|
||
await rm(parentDir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('rejects an Orca-created unregistered local directory with a git directory', async () => {
|
||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-standalone-'))
|
||
const repoPath = join(parentDir, 'repo')
|
||
const standalonePath = join(parentDir, 'standalone')
|
||
await mkdir(join(standalonePath, '.git'), { recursive: true })
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: repoPath,
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
worktreeBaseRef: null
|
||
})
|
||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||
store.getWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' })
|
||
)
|
||
|
||
try {
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: `repo-1::${standalonePath}`,
|
||
force: true
|
||
})
|
||
).rejects.toThrow(`Refusing to delete unregistered worktree path: ${standalonePath}`)
|
||
|
||
await expect(lstat(standalonePath)).resolves.toBeTruthy()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
} finally {
|
||
await rm(parentDir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not inspect or delete a local path when SSH orphan cleanup has no filesystem provider', async () => {
|
||
const localPath = await mkdtemp(join(tmpdir(), 'orca-ipc-ssh-missing-fs-'))
|
||
const repo = {
|
||
id: 'repo-ssh-missing-fs',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-missing-fs'
|
||
}
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'ssh' })
|
||
)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(undefined)
|
||
|
||
try {
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: `${repo.id}::${localPath}`,
|
||
force: true
|
||
})
|
||
).rejects.toThrow('SSH filesystem provider unavailable')
|
||
|
||
await expect(lstat(localPath)).resolves.toBeTruthy()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
} finally {
|
||
await rm(localPath, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('refuses SSH orphan cleanup when remote .git is a symlink', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh-symlink-git',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-symlink-git'
|
||
}
|
||
const worktreePath = '/remote/orphan'
|
||
const provider = {
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/remote/repo',
|
||
head: 'main',
|
||
branch: 'refs/heads/main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
}
|
||
]),
|
||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||
}
|
||
const fsProvider = {
|
||
lstat: vi.fn().mockResolvedValue({ type: 'symlink' }),
|
||
stat: vi.fn().mockResolvedValue({ type: 'directory' }),
|
||
readFile: vi.fn(),
|
||
deletePath: vi.fn()
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
store.getWorktreeMeta.mockReturnValue(
|
||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'ssh' })
|
||
)
|
||
getSshGitProviderMock.mockReturnValue(provider)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: `${repo.id}::${worktreePath}`,
|
||
force: true
|
||
})
|
||
).rejects.toThrow(`Refusing to delete unregistered worktree path: ${worktreePath}`)
|
||
|
||
expect(fsProvider.lstat).toHaveBeenCalledWith(`${worktreePath}/.git`)
|
||
expect(fsProvider.readFile).not.toHaveBeenCalled()
|
||
expect(fsProvider.deletePath).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('coalesces concurrent deletes for the same worktree id', async () => {
|
||
mockKnownFeatureWorktree()
|
||
deleteWorktreeHistoryDirMock.mockClear()
|
||
let removalStarted!: () => void
|
||
let finishRemoval!: () => void
|
||
const started = new Promise<void>((resolve) => {
|
||
removalStarted = resolve
|
||
})
|
||
removeWorktreeMock.mockImplementation(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
removalStarted()
|
||
finishRemoval = resolve
|
||
})
|
||
)
|
||
|
||
const first = handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
force: true
|
||
}) as Promise<unknown>
|
||
const second = handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
hostId: 'local',
|
||
force: true
|
||
}) as Promise<unknown>
|
||
|
||
await started
|
||
await Promise.resolve()
|
||
expect(removeWorktreeMock).toHaveBeenCalledTimes(1)
|
||
|
||
finishRemoval()
|
||
await expect(Promise.all([first, second])).resolves.toEqual([{}, {}])
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledTimes(1)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('rejects concurrent deletes for the same worktree id with different options', async () => {
|
||
mockKnownFeatureWorktree()
|
||
let removalStarted!: () => void
|
||
let finishRemoval!: () => void
|
||
const started = new Promise<void>((resolve) => {
|
||
removalStarted = resolve
|
||
})
|
||
removeWorktreeMock.mockImplementation(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
removalStarted()
|
||
finishRemoval = resolve
|
||
})
|
||
)
|
||
|
||
const first = handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
}) as Promise<unknown>
|
||
|
||
await started
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
hostId: 'local',
|
||
force: true
|
||
})
|
||
).rejects.toThrow('Worktree deletion already in progress')
|
||
|
||
expect(removeWorktreeMock).toHaveBeenCalledTimes(1)
|
||
finishRemoval()
|
||
await expect(first).resolves.toEqual({})
|
||
})
|
||
|
||
it('still rejects forced unregistered delete paths that exist on disk', async () => {
|
||
mockKnownFeatureWorktree('/workspace/real-feature')
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: `repo-1::${process.cwd()}`,
|
||
force: true
|
||
})
|
||
).rejects.toThrow('Refusing to delete unregistered worktree path')
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rejects the main worktree before teardown, hooks, or git removal', async () => {
|
||
mockKnownFeatureWorktree()
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/repo'
|
||
})
|
||
).rejects.toThrow('Refusing to delete protected worktree path')
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rejects deleting a worktree that contains another registered worktree before teardown, hooks, or git removal', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/parent',
|
||
head: 'parent',
|
||
branch: 'parent',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
},
|
||
{
|
||
path: '/workspace/parent/child',
|
||
head: 'child',
|
||
branch: 'child',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
archive: 'echo archived'
|
||
}
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/parent',
|
||
force: true
|
||
})
|
||
).rejects.toThrow(
|
||
'Refusing to delete worktree because it contains another registered worktree: /workspace/parent/child'
|
||
)
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('IPC-initiated delete kills PTYs BEFORE git-level removal (design §4.3)', async () => {
|
||
mockKnownFeatureWorktree()
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
const callOrder: string[] = []
|
||
assertWorktreeCleanForRemovalMock.mockImplementation(async () => {
|
||
callOrder.push('preflight')
|
||
})
|
||
killAllProcessesForWorktreeMock.mockImplementation(async () => {
|
||
callOrder.push('kill')
|
||
return { runtimeStopped: 1, providerStopped: 0, registryStopped: 0 }
|
||
})
|
||
removeWorktreeMock.mockImplementation(async () => {
|
||
callOrder.push('git')
|
||
})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/feature-wt',
|
||
expect.objectContaining({
|
||
localProvider: expect.anything(),
|
||
onPtyStopped: clearProviderPtyStateMock
|
||
})
|
||
)
|
||
expect(removeWorktreeMock).toHaveBeenCalled()
|
||
expect(callOrder).toEqual(['preflight', 'kill', 'git'])
|
||
})
|
||
|
||
it('routes local worktree removal through the selected WSL project runtime', async () => {
|
||
mockSelectedWslProjectRuntime()
|
||
mockKnownFeatureWorktree()
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
removeWorktreeMock.mockResolvedValue({})
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||
expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith('/workspace/feature-wt', false, {
|
||
wslDistro: 'Ubuntu'
|
||
})
|
||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-wt',
|
||
false,
|
||
expect.objectContaining({ wslDistro: 'Ubuntu' })
|
||
)
|
||
})
|
||
|
||
it('surfaces selected-runtime list failures during local worktree removal', async () => {
|
||
mockSelectedWslProjectRuntime()
|
||
const listError = new Error('wsl git list failed')
|
||
listWorktreesMock.mockRejectedValue(listError)
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
).rejects.toThrow('wsl git list failed')
|
||
|
||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('fails dirty non-force deletes before PTY teardown', async () => {
|
||
mockKnownFeatureWorktree()
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
assertWorktreeCleanForRemovalMock.mockRejectedValue(
|
||
Object.assign(new Error('Worktree has uncommitted or untracked changes.'), {
|
||
stdout: '?? scratch.txt\n'
|
||
})
|
||
)
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt')
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('fails locked dirty-force deletes before hooks, link cleanup, or PTY teardown', async () => {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path: '/workspace/feature-wt',
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false,
|
||
locked: true,
|
||
lockReason: 'active agent session'
|
||
}
|
||
])
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
symlinkPaths: ['node_modules']
|
||
})
|
||
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
force: true
|
||
})
|
||
).rejects.toThrow(
|
||
'Failed to force delete worktree at /workspace/feature-wt. Worktree is locked by Git.'
|
||
)
|
||
|
||
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
|
||
expect(runHookMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rechecks a local Git lock after the archive hook before teardown', async () => {
|
||
const unlockedWorktrees = mockKnownFeatureWorktree()
|
||
const lockedWorktrees = unlockedWorktrees.map((worktree) =>
|
||
worktree.path === '/workspace/feature-wt'
|
||
? { ...worktree, locked: true, lockReason: 'locked during archive' }
|
||
: worktree
|
||
)
|
||
listWorktreesMock
|
||
.mockResolvedValueOnce(unlockedWorktrees)
|
||
.mockResolvedValueOnce(lockedWorktrees)
|
||
store.getRepo.mockReturnValue({
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
symlinkPaths: ['node_modules']
|
||
})
|
||
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||
runHookMock.mockResolvedValue({ success: true, output: '' })
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||
force: true
|
||
})
|
||
).rejects.toThrow('Worktree is locked by Git')
|
||
|
||
expect(runHookMock).toHaveBeenCalled()
|
||
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
|
||
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('formats preflight subprocess failures and does not tear down PTYs', async () => {
|
||
mockKnownFeatureWorktree()
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
assertWorktreeCleanForRemovalMock.mockRejectedValue(
|
||
Object.assign(new Error('status failed'), {
|
||
stderr: 'fatal: unable to read current working directory\n'
|
||
})
|
||
)
|
||
|
||
await expect(
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
).rejects.toThrow(
|
||
'Failed to delete worktree at /workspace/feature-wt. fatal: unable to read current working directory'
|
||
)
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('falls through to orphan cleanup when preflight reports missing/non-repo worktree', async () => {
|
||
mockKnownFeatureWorktree()
|
||
getEffectiveHooksMock.mockReturnValue(null)
|
||
assertWorktreeCleanForRemovalMock.mockRejectedValue(
|
||
Object.assign(new Error('status failed'), {
|
||
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
|
||
})
|
||
)
|
||
removeWorktreeMock.mockRejectedValue(
|
||
Object.assign(new Error('git worktree remove failed'), {
|
||
stderr: "fatal: '/workspace/feature-wt' is not a working tree"
|
||
})
|
||
)
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||
|
||
await handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||
})
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).toHaveBeenCalled()
|
||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||
cwd: '/workspace/repo'
|
||
})
|
||
})
|
||
|
||
it('skips the PTY teardown for SSH-backed repos (design §6 out-of-scope)', async () => {
|
||
// Why: SSH-backed PTYs live on the remote host and are handled by the
|
||
// remote provider's own teardown. The local-host helper must not run for
|
||
// SSH repos, because it would sweep registry entries for other worktrees.
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
store.getRepos.mockReturnValue([repo])
|
||
store.getRepo.mockReturnValue(repo)
|
||
|
||
// The test can't easily mock the SSH provider without more plumbing — the
|
||
// call will throw about 'no git provider for connection'. What matters
|
||
// here is that the kill helper was NOT called for the SSH branch.
|
||
await (
|
||
handlers['worktrees:remove'](null, {
|
||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||
}) as Promise<unknown>
|
||
).catch(() => {})
|
||
|
||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps SSH issue-command local overrides usable when shared read fails', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn(async (filePath: string) => {
|
||
if (filePath.endsWith('/.orca/issue-command')) {
|
||
return { content: 'local command\n', isBinary: false }
|
||
}
|
||
throw new Error('shared read failed')
|
||
})
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
|
||
await expect(
|
||
handlers['hooks:readIssueCommand'](null, {
|
||
repoId: 'repo-ssh'
|
||
})
|
||
).resolves.toMatchObject({
|
||
status: 'ok',
|
||
localContent: 'local command',
|
||
sharedContent: null,
|
||
effectiveContent: 'local command',
|
||
source: 'local'
|
||
})
|
||
})
|
||
|
||
it('writes SSH issue-command overrides without clobbering .gitignore on read failure', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const fsProvider = {
|
||
createDir: vi.fn().mockResolvedValue(undefined),
|
||
readFile: vi.fn().mockRejectedValue(new Error('ssh read failed')),
|
||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
|
||
await expect(
|
||
handlers['hooks:writeIssueCommand'](null, {
|
||
repoId: 'repo-ssh',
|
||
content: 'orca issue command'
|
||
})
|
||
).rejects.toThrow('ssh read failed')
|
||
|
||
expect(fsProvider.writeFile).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('reads an issue-command override from the requested host when repo ids collide', async () => {
|
||
const localRepo = {
|
||
id: 'repo-shared',
|
||
path: '/local/repo',
|
||
displayName: 'local',
|
||
badgeColor: '#000',
|
||
addedAt: 0
|
||
}
|
||
const sshRepo = {
|
||
...localRepo,
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
connectionId: 'conn-1'
|
||
}
|
||
const fsProvider = {
|
||
readFile: vi.fn(async (filePath: string) => {
|
||
if (filePath.endsWith('/.orca/issue-command')) {
|
||
return { content: 'remote command\n', isBinary: false }
|
||
}
|
||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||
})
|
||
}
|
||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||
store.getRepo.mockReturnValue(localRepo)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
|
||
await expect(
|
||
handlers['hooks:readIssueCommand'](null, {
|
||
repoId: 'repo-shared',
|
||
hostId: 'ssh:conn-1'
|
||
})
|
||
).resolves.toMatchObject({
|
||
localContent: 'remote command',
|
||
effectiveContent: 'remote command',
|
||
source: 'local'
|
||
})
|
||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command')
|
||
})
|
||
|
||
it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||
const fsProvider = {
|
||
createDir: vi.fn().mockResolvedValue(undefined),
|
||
readFile: vi.fn().mockRejectedValue(enoent),
|
||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||
|
||
await handlers['hooks:writeIssueCommand'](null, {
|
||
repoId: 'repo-ssh',
|
||
content: 'orca issue command'
|
||
})
|
||
|
||
expect(fsProvider.writeFile).toHaveBeenNthCalledWith(1, '/remote/repo/.gitignore', '.orca\n')
|
||
expect(fsProvider.writeFile).toHaveBeenNthCalledWith(
|
||
2,
|
||
'/remote/repo/.orca/issue-command',
|
||
'orca issue command\n'
|
||
)
|
||
})
|
||
|
||
it('rejects SSH issue-command writes when the remote filesystem provider is unavailable', async () => {
|
||
const repo = {
|
||
id: 'repo-ssh',
|
||
path: '/remote/repo',
|
||
displayName: 'ssh',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'conn-1',
|
||
worktreeBaseRef: null
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
getSshFilesystemProviderMock.mockReturnValue(null)
|
||
|
||
await expect(
|
||
handlers['hooks:writeIssueCommand'](null, {
|
||
repoId: 'repo-ssh',
|
||
content: 'orca issue command'
|
||
})
|
||
).rejects.toThrow('Remote filesystem unavailable')
|
||
})
|
||
|
||
it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => {
|
||
getEffectiveHooksMock.mockReturnValue({
|
||
scripts: {
|
||
setup: 'pnpm worktree:setup'
|
||
}
|
||
})
|
||
shouldRunSetupForCreateMock.mockImplementation(() => {
|
||
throw new Error('Setup decision required for this repository')
|
||
})
|
||
|
||
await expect(
|
||
handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
})
|
||
).rejects.toThrow('Setup decision required for this repository')
|
||
|
||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
describe('worktrees:forgetLocal', () => {
|
||
it('forgets a workspace pinned to a removed SSH target without touching the provider', async () => {
|
||
const repo = {
|
||
id: 'repo-1',
|
||
path: '/workspace/repo',
|
||
displayName: 'repo',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
connectionId: 'ssh-dead',
|
||
worktreeBaseRef: null
|
||
}
|
||
const ptyProvider = {} as never
|
||
const worktreeId = 'repo-1::/workspace/feature-wt'
|
||
store.getRepo.mockReturnValue(repo)
|
||
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
|
||
// Why: a removed/disconnected SSH target has no live provider; forgetLocal
|
||
// must not reach for one.
|
||
getSshGitProviderMock.mockReturnValue(undefined)
|
||
|
||
const result = await handlers['worktrees:forgetLocal'](null, { worktreeId })
|
||
|
||
expect(result).toEqual({})
|
||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||
runtime: runtimeStub,
|
||
localProvider: ptyProvider,
|
||
onPtyStopped: clearProviderPtyStateMock
|
||
})
|
||
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
|
||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||
expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||
repoId: 'repo-1'
|
||
})
|
||
// The whole point: local-only cleanup never dispatches to the SSH provider.
|
||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
|
||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('throws when the repo is missing', async () => {
|
||
store.getRepo.mockReturnValue(undefined)
|
||
|
||
await expect(
|
||
handlers['worktrees:forgetLocal'](null, {
|
||
worktreeId: 'repo-gone::/workspace/feature-wt'
|
||
})
|
||
).rejects.toThrow(/Repo not found/)
|
||
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rejects forgetting a folder project root', async () => {
|
||
const repo = {
|
||
id: 'repo-folder',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
badgeColor: '#000',
|
||
addedAt: 0,
|
||
kind: 'folder' as const
|
||
}
|
||
store.getRepo.mockReturnValue(repo)
|
||
|
||
await expect(
|
||
handlers['worktrees:forgetLocal'](null, {
|
||
worktreeId: `${repo.id}::${repo.path}`
|
||
})
|
||
).rejects.toThrow(/project root workspace/)
|
||
|
||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
})
|