Files
orca/src/main/ipc/worktrees-remove-preflight.test.ts
T
OrcaWinandm4air 3359fd2bfc refactor(ipc): worktree handlers are local or runtime only (#27528)
* refactor(ipc): worktree handlers are local or runtime only

* refactor(runtime): explain the route witness on removal listing

* test(runtime): pin the SSH create refusal and drop the deleted branch's reliability gap

* test(runtime): drop a needless cast in the SSH create refusal test

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 11:48:57 -07:00

295 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as filesystemAuth from './filesystem-auth'
import * as authorizedRootsCache from './registered-worktree-roots-cache'
import {
removeWorktreeLinkedPathsMock,
findExistingWorktreeSymlinkPathsMock,
listWorktreesMock,
assertWorktreeCleanForRemovalMock,
removeWorktreeMock,
getEffectiveHooksMock,
runHookMock,
gitExecFileAsyncMock,
killAllProcessesForWorktreeMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-git-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
describe('registerWorktreeHandlers', () => {
let runtimeStub: WorktreeRuntimeStub
beforeEach(() => {
runtimeStub = setupWorktreeHandlers()
vi.spyOn(filesystemAuth, 'invalidateAuthorizedRootsCacheForRepo').mockClear()
vi.spyOn(authorizedRootsCache, 'invalidateAuthorizedRootsCache').mockClear()
})
it('fails dirty non-force deletes before PTY teardown', async () => {
mockKnownFeatureWorktree()
const repoWithConfiguredRegularFile = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
symlinkPaths: ['scratch.txt']
}
store.getRepo.mockReturnValue(repoWithConfiguredRegularFile)
store.getRepos.mockReturnValue([repoWithConfiguredRegularFile])
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(findExistingWorktreeSymlinkPathsMock).toHaveBeenCalledWith('/workspace/feature-wt', [
'scratch.txt'
])
expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith('/workspace/feature-wt', false)
expect(runtimeStub.closeFileWatchersForRemoval).not.toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled()
expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled()
})
it('propagates a timed-out removal preflight before watcher teardown', async () => {
mockKnownFeatureWorktree()
getEffectiveHooksMock.mockReturnValue(null)
assertWorktreeCleanForRemovalMock.mockRejectedValue(new Error('git timed out.'))
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt. git timed out.')
expect(runtimeStub.closeFileWatchersForRemoval).not.toHaveBeenCalled()
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()
expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled()
expect(authorizedRootsCache.invalidateAuthorizedRootsCache).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).toHaveBeenCalledWith(
'repo-1::/workspace/feature-wt',
expect.objectContaining({ requirePhysicalStop: true })
)
expect(removeWorktreeMock).toHaveBeenCalled()
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
cwd: '/workspace/repo'
})
})
})