mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* 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>
295 lines
11 KiB
TypeScript
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'
|
|
})
|
|
})
|
|
})
|