mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
2168 lines
67 KiB
TypeScript
2168 lines
67 KiB
TypeScript
/* eslint-disable max-lines */
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const {
|
||
handleMock,
|
||
removeHandlerMock,
|
||
listWorktreesMock,
|
||
assertWorktreeCleanForRemovalMock,
|
||
addWorktreeMock,
|
||
addSparseWorktreeMock,
|
||
removeWorktreeMock,
|
||
getGitUsernameMock,
|
||
getDefaultBaseRefMock,
|
||
getDefaultRemoteMock,
|
||
getBranchConflictKindMock,
|
||
getPRForBranchMock,
|
||
getWorkItemMock,
|
||
getPullRequestPushTargetMock,
|
||
getEffectiveHooksMock,
|
||
createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScriptMock,
|
||
shouldRunSetupForCreateMock,
|
||
runHookMock,
|
||
hasHooksFileMock,
|
||
loadHooksMock,
|
||
computeWorktreePathMock,
|
||
ensurePathWithinWorkspaceMock,
|
||
gitExecFileAsyncMock,
|
||
getSshGitProviderMock,
|
||
getActiveMultiplexerMock
|
||
} = vi.hoisted(() => ({
|
||
handleMock: vi.fn(),
|
||
removeHandlerMock: vi.fn(),
|
||
listWorktreesMock: vi.fn(),
|
||
assertWorktreeCleanForRemovalMock: vi.fn(),
|
||
addWorktreeMock: vi.fn(),
|
||
addSparseWorktreeMock: vi.fn(),
|
||
removeWorktreeMock: vi.fn(),
|
||
getGitUsernameMock: vi.fn(),
|
||
getDefaultBaseRefMock: vi.fn(),
|
||
getDefaultRemoteMock: vi.fn(),
|
||
getBranchConflictKindMock: vi.fn(),
|
||
getPRForBranchMock: vi.fn(),
|
||
getWorkItemMock: vi.fn(),
|
||
getPullRequestPushTargetMock: vi.fn(),
|
||
getEffectiveHooksMock: vi.fn(),
|
||
createIssueCommandRunnerScriptMock: vi.fn(),
|
||
createSetupRunnerScriptMock: vi.fn(),
|
||
shouldRunSetupForCreateMock: vi.fn(),
|
||
runHookMock: vi.fn(),
|
||
hasHooksFileMock: vi.fn(),
|
||
loadHooksMock: vi.fn(),
|
||
computeWorktreePathMock: vi.fn(),
|
||
ensurePathWithinWorkspaceMock: vi.fn(),
|
||
gitExecFileAsyncMock: vi.fn(),
|
||
getSshGitProviderMock: vi.fn(),
|
||
getActiveMultiplexerMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('electron', () => ({
|
||
ipcMain: {
|
||
handle: handleMock,
|
||
removeHandler: removeHandlerMock
|
||
}
|
||
}))
|
||
|
||
vi.mock('../git/worktree', () => ({
|
||
listWorktrees: listWorktreesMock,
|
||
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
|
||
addWorktree: addWorktreeMock,
|
||
addSparseWorktree: addSparseWorktreeMock,
|
||
removeWorktree: removeWorktreeMock
|
||
}))
|
||
|
||
vi.mock('../git/runner', () => ({
|
||
gitExecFileAsync: gitExecFileAsyncMock,
|
||
gitExecFileSync: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../git/repo', () => ({
|
||
getGitUsername: getGitUsernameMock,
|
||
getDefaultBaseRef: getDefaultBaseRefMock,
|
||
getDefaultRemote: getDefaultRemoteMock,
|
||
getBranchConflictKind: getBranchConflictKindMock
|
||
}))
|
||
|
||
vi.mock('../github/client', () => ({
|
||
getPRForBranch: getPRForBranchMock,
|
||
getWorkItem: getWorkItemMock,
|
||
getPullRequestPushTarget: getPullRequestPushTargetMock
|
||
}))
|
||
|
||
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('./ssh', () => ({
|
||
getActiveMultiplexer: getActiveMultiplexerMock
|
||
}))
|
||
|
||
vi.mock('../hooks', () => ({
|
||
createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScript: createSetupRunnerScriptMock,
|
||
getEffectiveHooks: getEffectiveHooksMock,
|
||
loadHooks: loadHooksMock,
|
||
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 { killAllProcessesForWorktreeMock, getLocalPtyProviderMock } = vi.hoisted(() => ({
|
||
killAllProcessesForWorktreeMock: vi.fn(),
|
||
getLocalPtyProviderMock: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../runtime/worktree-teardown', () => ({
|
||
killAllProcessesForWorktree: killAllProcessesForWorktreeMock
|
||
}))
|
||
|
||
vi.mock('./pty', () => ({
|
||
getLocalPtyProvider: getLocalPtyProviderMock
|
||
}))
|
||
|
||
import { 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(),
|
||
getSparsePresets: vi.fn(),
|
||
getSettings: vi.fn(),
|
||
getWorktreeMeta: vi.fn(),
|
||
getAllWorktreeMeta: vi.fn(),
|
||
setWorktreeMeta: vi.fn(),
|
||
removeWorktreeMeta: vi.fn(),
|
||
getAllWorktreeLineage: vi.fn(),
|
||
removeWorktreeLineage: vi.fn()
|
||
}
|
||
let runtimeStub: {
|
||
resolveRemoteTrackingBase: ReturnType<typeof vi.fn>
|
||
hasRemoteTrackingRef: ReturnType<typeof vi.fn>
|
||
isRemoteFetchFresh: 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>
|
||
}
|
||
|
||
beforeEach(() => {
|
||
for (const m of [
|
||
handleMock,
|
||
removeHandlerMock,
|
||
listWorktreesMock,
|
||
assertWorktreeCleanForRemovalMock,
|
||
addWorktreeMock,
|
||
addSparseWorktreeMock,
|
||
removeWorktreeMock,
|
||
getGitUsernameMock,
|
||
getDefaultBaseRefMock,
|
||
getDefaultRemoteMock,
|
||
getBranchConflictKindMock,
|
||
getPRForBranchMock,
|
||
getWorkItemMock,
|
||
getPullRequestPushTargetMock,
|
||
getEffectiveHooksMock,
|
||
createIssueCommandRunnerScriptMock,
|
||
createSetupRunnerScriptMock,
|
||
shouldRunSetupForCreateMock,
|
||
runHookMock,
|
||
hasHooksFileMock,
|
||
loadHooksMock,
|
||
computeWorktreePathMock,
|
||
ensurePathWithinWorkspaceMock,
|
||
gitExecFileAsyncMock,
|
||
getSshGitProviderMock,
|
||
getActiveMultiplexerMock,
|
||
mainWindow.webContents.send,
|
||
store.getRepos,
|
||
store.getRepo,
|
||
store.getSparsePresets,
|
||
store.getSettings,
|
||
store.getWorktreeMeta,
|
||
store.getAllWorktreeMeta,
|
||
store.setWorktreeMeta,
|
||
store.removeWorktreeMeta,
|
||
store.getAllWorktreeLineage,
|
||
store.removeWorktreeLineage,
|
||
killAllProcessesForWorktreeMock,
|
||
getLocalPtyProviderMock
|
||
]) {
|
||
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.getSparsePresets.mockReturnValue([])
|
||
store.getSettings.mockReturnValue({
|
||
branchPrefix: 'none',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||
workspaceDir: '/workspace'
|
||
})
|
||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||
store.getAllWorktreeMeta.mockReturnValue({})
|
||
store.setWorktreeMeta.mockReturnValue({})
|
||
store.getAllWorktreeLineage.mockReturnValue({})
|
||
getGitUsernameMock.mockReturnValue('')
|
||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||
getBranchConflictKindMock.mockResolvedValue(null)
|
||
getPRForBranchMock.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)
|
||
shouldRunSetupForCreateMock.mockReturnValue(false)
|
||
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([])
|
||
|
||
// 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),
|
||
isRemoteFetchFresh: vi.fn().mockResolvedValue(false),
|
||
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()
|
||
}
|
||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||
})
|
||
|
||
function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void {
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/repo',
|
||
head: 'main',
|
||
branch: 'main',
|
||
isBare: false,
|
||
isMainWorktree: true
|
||
},
|
||
{
|
||
path,
|
||
head: 'feature',
|
||
branch: 'feature',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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'
|
||
})
|
||
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/improve-dashboard-2',
|
||
'improve-dashboard-2',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(result).toEqual({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/improve-dashboard-2',
|
||
branch: 'improve-dashboard-2'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => {
|
||
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(result).toEqual({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/feature-something',
|
||
branch: 'feature/something'
|
||
})
|
||
})
|
||
})
|
||
|
||
it('suffixes branchNameOverride without flattening slashes when the first 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: 'feature/something-2',
|
||
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-2'],
|
||
{ cwd: '/workspace/repo' }
|
||
)
|
||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-something-2',
|
||
'feature/something-2',
|
||
'origin/main',
|
||
false
|
||
)
|
||
expect(result).toEqual({
|
||
worktree: expect.objectContaining({
|
||
path: '/workspace/feature-something-2',
|
||
branch: 'feature/something-2'
|
||
})
|
||
})
|
||
})
|
||
|
||
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).toEqual({
|
||
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'
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-1::/workspace/improve-dashboard',
|
||
expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'ENG-123'
|
||
})
|
||
)
|
||
expect(result).toEqual({
|
||
worktree: expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
linkedLinearIssue: 'ENG-123'
|
||
})
|
||
})
|
||
})
|
||
|
||
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).toEqual({
|
||
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: {
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git'
|
||
}
|
||
})
|
||
)
|
||
})
|
||
|
||
it('returns the PR head push target when resolving a fork PR base', async () => {
|
||
getPullRequestPushTargetMock.mockResolvedValue({
|
||
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).toEqual({
|
||
baseBranch: 'abc123',
|
||
pushTarget: {
|
||
remoteName: 'pr-prateek-orca',
|
||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||
remoteUrl: 'git@github.com:prateek/orca.git'
|
||
}
|
||
})
|
||
})
|
||
|
||
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' })
|
||
})
|
||
|
||
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' })
|
||
})
|
||
|
||
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).toEqual({
|
||
error:
|
||
'Failed to fetch origin/feat/onboarding-model-choice-782: fatal: unable to access repo: Could not resolve host: github.com'
|
||
})
|
||
})
|
||
|
||
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().mockResolvedValue({ stdout: '', stderr: '' }),
|
||
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 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'
|
||
})
|
||
|
||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||
'repo-ssh::/remote/improve-dashboard',
|
||
expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
createdWithAgent: 'codex',
|
||
linkedLinearIssue: 'ENG-123'
|
||
})
|
||
)
|
||
expect(result).toEqual({
|
||
worktree: expect.objectContaining({
|
||
linkedIssue: 123,
|
||
linkedPR: 456,
|
||
createdWithAgent: 'codex',
|
||
linkedLinearIssue: 'ENG-123'
|
||
})
|
||
})
|
||
})
|
||
|
||
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('does not await a cold fetch when the remote-tracking base exists locally', 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.getOrStartRemoteFetch.mockReturnValue(pendingFetch)
|
||
listWorktreesMock.mockResolvedValue([
|
||
{
|
||
path: '/workspace/improve-dashboard',
|
||
head: 'created-sha',
|
||
branch: 'improve-dashboard',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
])
|
||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||
|
||
const createPromise = handlers['worktrees:create'](null, {
|
||
repoId: 'repo-1',
|
||
name: 'improve-dashboard'
|
||
}) as Promise<unknown>
|
||
|
||
const result = await Promise.race([
|
||
createPromise,
|
||
new Promise((resolve) => setTimeout(() => resolve('timed-out'), 0))
|
||
])
|
||
expect(result).not.toBe('timed-out')
|
||
|
||
expect(runtimeStub.getOrStartRemoteFetch).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||
expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({
|
||
repoId: 'repo-1',
|
||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||
status: 'checking',
|
||
base: 'origin/main',
|
||
remote: 'origin'
|
||
})
|
||
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
createdWorktreePath: '/workspace/improve-dashboard',
|
||
createdInstanceId: expect.any(String),
|
||
createdBaseSha: 'created-sha',
|
||
fetchPromise: pendingFetch
|
||
})
|
||
)
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' })
|
||
})
|
||
)
|
||
resolveFetch()
|
||
})
|
||
|
||
it('schedules optimistic reconcile when remote-tracking base freshness is cached', async () => {
|
||
const remoteBase = {
|
||
remote: 'origin',
|
||
branch: 'main',
|
||
ref: 'refs/remotes/origin/main',
|
||
base: 'origin/main'
|
||
}
|
||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||
runtimeStub.isRemoteFetchFresh.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'
|
||
})
|
||
|
||
expect(runtimeStub.getOrStartRemoteFetch).not.toHaveBeenCalled()
|
||
expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({
|
||
repoId: 'repo-1',
|
||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||
status: 'checking',
|
||
base: 'origin/main',
|
||
remote: 'origin'
|
||
})
|
||
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
createdWorktreePath: '/workspace/improve-dashboard',
|
||
createdInstanceId: expect.any(String),
|
||
createdBaseSha: 'created-sha',
|
||
fetchPromise: expect.any(Promise)
|
||
})
|
||
)
|
||
await expect(
|
||
runtimeStub.reconcileWorktreeBaseStatus.mock.calls[0][0].fetchPromise
|
||
).resolves.toEqual({ ok: true })
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' })
|
||
})
|
||
)
|
||
})
|
||
|
||
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
|
||
// getDefaultBaseRef 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`.
|
||
getDefaultBaseRefMock.mockReturnValue(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).toEqual({
|
||
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 () => {
|
||
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'
|
||
})
|
||
|
||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||
|
||
expect(listed).toEqual([
|
||
expect.objectContaining({
|
||
id: 'repo-1::/workspace/folder',
|
||
repoId: 'repo-1',
|
||
path: '/workspace/folder',
|
||
displayName: 'folder',
|
||
branch: '',
|
||
head: '',
|
||
isMainWorktree: true
|
||
})
|
||
])
|
||
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).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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 = { 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) })
|
||
)
|
||
expect(listed[0]).toMatchObject({
|
||
id: 'repo-1::/workspace/discovered-wt',
|
||
lastActivityAt: 1_700_000_000_000
|
||
})
|
||
})
|
||
|
||
it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => {
|
||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||
// would overwrite real activity and reshuffle the sidebar on refresh.
|
||
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
|
||
})
|
||
|
||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||
id: string
|
||
lastActivityAt: number
|
||
}[]
|
||
|
||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||
expect(listed[0].lastActivityAt).toBe(42)
|
||
})
|
||
|
||
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',
|
||
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) })
|
||
)
|
||
expect(listed[0].instanceId).toBe('new-instance')
|
||
})
|
||
|
||
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({ 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) })
|
||
)
|
||
})
|
||
|
||
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('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).toEqual({
|
||
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).toEqual({
|
||
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('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'
|
||
}
|
||
}))
|
||
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).toEqual({
|
||
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).toEqual({
|
||
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('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' })
|
||
)
|
||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||
'/workspace/repo',
|
||
'/workspace/feature-wt',
|
||
false
|
||
)
|
||
})
|
||
|
||
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
|
||
)
|
||
})
|
||
|
||
it('rejects 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('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('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()
|
||
})
|
||
)
|
||
expect(removeWorktreeMock).toHaveBeenCalled()
|
||
expect(callOrder).toEqual(['preflight', 'kill', 'git'])
|
||
})
|
||
|
||
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('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('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()
|
||
})
|
||
})
|