From 206a70964ef2dae71c388e2346f40fdfe2c2d02d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:53:07 -0700 Subject: [PATCH] Recover partially deleted local worktrees (#6657) Co-authored-by: Neil --- src/main/ipc/worktrees.test.ts | 87 +++++++++ src/main/ipc/worktrees.ts | 91 ++++++++- src/main/local-worktree-filesystem.test.ts | 17 ++ src/main/local-worktree-filesystem.ts | 17 +- src/main/runtime/orca-runtime.test.ts | 119 ++++++++++++ src/main/runtime/orca-runtime.ts | 94 ++++++++- src/main/worktree-removal-safety.test.ts | 209 +++++++++++++++++++++ src/main/worktree-removal-safety.ts | 79 +++++++- 8 files changed, 691 insertions(+), 22 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8a8a3be98..a7716a039 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6573,6 +6573,93 @@ describe('registerWorktreeHandlers', () => { } }) + it('prompts then force-removes an Orca-created unregistered leftover directory with no git marker', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-leftover-')) + const repoPath = join(parentDir, 'repo') + const leftoverPath = join(parentDir, 'leftover') + const worktreeId = `repo-1::${leftoverPath}` + await mkdir(leftoverPath, { recursive: true }) + await writeFile(join(leftoverPath, 'leftover.txt'), 'kept until force\n') + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + }) + mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' }) + ) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'status') { + throw new Error('fatal: not a git repository') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(leftoverPath)).resolves.toBeTruthy() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + + await expect( + handlers['worktrees:remove'](null, { worktreeId, force: true }) + ).resolves.toEqual({}) + + await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('rejects an Orca-created unregistered local directory with a git directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-standalone-')) + const repoPath = join(parentDir, 'repo') + const standalonePath = join(parentDir, 'standalone') + await mkdir(join(standalonePath, '.git'), { recursive: true }) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + }) + mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' }) + ) + + try { + await expect( + handlers['worktrees:remove'](null, { + worktreeId: `repo-1::${standalonePath}`, + force: true + }) + ).rejects.toThrow(`Refusing to delete unregistered worktree path: ${standalonePath}`) + + await expect(lstat(standalonePath)).resolves.toBeTruthy() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('does not inspect or delete a local path when SSH orphan cleanup has no filesystem provider', async () => { const localPath = await mkdtemp(join(tmpdir(), 'orca-ipc-ssh-missing-fs-')) const repo = { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 5fd4d565f..de76cc8b4 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -104,9 +104,11 @@ import { classifyWorkspaceCreateError } from './workspace-create-error-classifie import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaLeftoverDirectory, canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, + isDangerousWorktreeRemovalPath, isWorktreePathMissing, ORPHANED_WORKTREE_DIRECTORY_MESSAGE, stripOrcaProvenanceMetaUpdates, @@ -253,6 +255,37 @@ async function isAlreadyRemovedWorktreePath( return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) } +async function isLocalGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +function gitStatusErrorMeansNotRepository(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr) + : '' + return /not a git repository/i.test(`${message}\n${stderr}`) +} + function getWorktreeRemovalOptionsKey(args: { force?: boolean; skipArchive?: boolean }): string { const forceKey = args.force === true ? 'force' : 'normal' const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' @@ -1389,19 +1422,18 @@ export function registerWorktreeHandlers( ) } else { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) } } if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - registeredWorktrees - ) + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) if (!args.force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } @@ -1432,6 +1464,45 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return {} } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + worktreePath, + localWorktreeGitOptions + ) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + knownOrcaLayouts, + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + await closeLocalWatcherForRemoval(worktreePath) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + } if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { if (!args.force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index b41adb648..496d88b0e 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -29,6 +29,12 @@ function completeExecFile(stdout = ''): void { }) } +function failExecFile(error: Error & { code?: number | string }): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(error, '', '') + }) +} + async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { const original = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -167,4 +173,15 @@ describe('local worktree filesystem runtime access', () => { expect(rmMock).not.toHaveBeenCalled() }) }) + + it('reports missing WSL stat targets with an ENOENT-shaped error', async () => { + await withPlatform('win32', async () => { + failExecFile(Object.assign(new Error('missing'), { code: 2 })) + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + + await expect(access.statPath('/mnt/c/repo/missing/.git')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + }) }) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index eb7c888bb..aafb5ea93 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -73,6 +73,16 @@ function runWslLoginShellCommand(distro: string, command: string): Promise { + if (isWslMissingPathError(error)) { + throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) + } + throw error + }) return { type: stdout.trim() } }, readPath: async (path) => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ade0c2585..b2a0cd318 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -23113,6 +23113,125 @@ describe('OrcaRuntimeService', () => { } }) + it('prompts then force-removes an Orca-created runtime unregistered leftover directory with no git marker', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-leftover-')) + const repoPath = join(parentDir, 'repo') + const leftoverPath = join(parentDir, 'leftover') + const worktreeId = `${TEST_REPO_ID}::${leftoverPath}` + await mkdir(leftoverPath, { recursive: true }) + await writeFile(join(leftoverPath, 'leftover.txt'), 'kept until force\n') + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId, { + orcaCreatedAt: Date.now(), + orcaCreationSource: 'runtime' + }) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + const notifier = { worktreesChanged: vi.fn() } + runtime.setNotifier(notifier as never) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'status') { + throw new Error('fatal: not a git repository') + } + return { stdout: '', stderr: '' } + }) + + try { + vi.mocked(listWorktrees).mockResolvedValue([]) + + await expect(runtime.removeManagedWorktree(worktreeId)).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(leftoverPath)).resolves.toBeTruthy() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + + await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({}) + + await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) + expect(gitSpy).toHaveBeenCalledWith(['status', '--short'], { cwd: leftoverPath }) + } finally { + gitSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + }) + + it('rejects an Orca-created runtime unregistered local directory with a git directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-standalone-')) + const repoPath = join(parentDir, 'repo') + const standalonePath = join(parentDir, 'standalone') + const worktreeId = `${TEST_REPO_ID}::${standalonePath}` + await mkdir(join(standalonePath, '.git'), { recursive: true }) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId, { + orcaCreatedAt: Date.now(), + orcaCreationSource: 'runtime' + }) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + + try { + vi.mocked(listWorktrees).mockResolvedValue([]) + + await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow( + `Refusing to delete unregistered worktree path: ${standalonePath}` + ) + + await expect(lstat(standalonePath)).resolves.toBeTruthy() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + await rm(parentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + }) + it('does not inspect or delete a local path when SSH runtime orphan cleanup has no filesystem provider', async () => { const localPath = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-missing-fs-')) const repo = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 639e838f1..53fd0ffdc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -635,9 +635,11 @@ import { } from '../ipc/worktree-logic' import { assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaLeftoverDirectory, canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, + isDangerousWorktreeRemovalPath, isWorktreePathMissing, ORPHANED_WORKTREE_DIRECTORY_MESSAGE, stripOrcaProvenanceMetaUpdates, @@ -1248,6 +1250,37 @@ async function isRuntimeWorktreePathMissing( return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) } +async function isLocalRuntimeGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +function gitStatusErrorMeansNotRepository(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr) + : '' + return /not a git repository/i.test(`${message}\n${stderr}`) +} + type RuntimeWorktreeRemovalTarget = { id: string repoId: string @@ -14704,19 +14737,18 @@ export class OrcaRuntimeService { ) } else { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) } } if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree( - toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), - registeredWorktrees - ) + assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) if (!force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } @@ -14747,6 +14779,48 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) return {} } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + removalTarget.path, + localWorktreeGitOptions + ) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath: removalTarget.path, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + knownOrcaLayouts, + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + await closeLocalWatcherForWorktreePath(removalTarget.path).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${removalTarget.path}:`, err) + }) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + } if (await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions)) { if (!force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 5aeee3287..c645682cf 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' +import { homedir } from 'os' import type { GitWorktreeInfo } from '../shared/types' import { + canCleanupUnregisteredOrcaLeftoverDirectory, canSafelyRemoveOrphanedWorktreeDirectory, getRegisteredDeletableWorktree } from './worktree-removal-safety' @@ -298,4 +300,211 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { ) ).resolves.toBe(true) }) + + it('rejects POSIX home directories even when host homedir has a different path shape', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/home/dev', + '/repos/main', + makeStatPath(['/home/dev/.git'], ['/repos/main/.git']), + makeReadPath([ + ['/home/dev/.git', 'gitdir: /repos/main/.git/worktrees/dev\n'], + ['/repos/main/.git/worktrees/dev/gitdir', '/home/dev/.git\n'] + ]) + ) + ).resolves.toBe(false) + }) +}) + +describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { + const repo = { path: '/repos/main' } + const ownedMeta = { orcaCreatedAt: 1, orcaCreationSource: 'runtime' as const } + const baseArgs = { + meta: ownedMeta, + worktreePath: '/workspaces/orca-owned', + runtimeWorktreePath: '/workspaces/orca-owned', + repo, + runtimeRepoPath: repo.path, + knownOrcaLayouts: [], + registeredWorktrees: [makeGitWorktree(repo.path, true)] + } + + it('rejects unregistered existing targets that are files or symlinks', async () => { + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + statPath: makeStatPath(['/workspaces/orca-owned']), + isGitRepository + }) + ).resolves.toBe(false) + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + statPath: async (path) => { + if (path === '/workspaces/orca-owned') { + return { type: 'symlink' } + } + throw missingPath(path) + }, + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).not.toHaveBeenCalled() + }) + + it('rejects unregistered leftover directories with a .git marker', async () => { + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + statPath: makeStatPath(['/workspaces/orca-owned/.git'], ['/workspaces/orca-owned']), + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).not.toHaveBeenCalled() + }) + + it('rejects no-marker cleanup when only the Orca path shape matches', async () => { + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + meta: undefined, + knownOrcaLayouts: [{ path: '/workspaces', nestWorkspaces: false }], + statPath: makeStatPath([], ['/workspaces/orca-owned']), + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).not.toHaveBeenCalled() + }) + + it('checks dangerous paths in the original path space before runtime translation', async () => { + const homePath = homedir() + const runtimeHomePath = homePath + .replace(/^([A-Za-z]):/, (_match, drive: string) => `/mnt/${drive.toLowerCase()}`) + .replace(/\\/g, '/') + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + worktreePath: homePath, + runtimeWorktreePath: runtimeHomePath, + repo: { path: 'C:\\repos\\main' }, + runtimeRepoPath: '/mnt/c/repos/main', + registeredWorktrees: [makeGitWorktree('C:\\repos\\main', true)], + statPath: makeStatPath([], [runtimeHomePath]), + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).not.toHaveBeenCalled() + }) + + it('checks dangerous POSIX runtime home paths before no-marker cleanup', async () => { + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + worktreePath: '/home/dev', + runtimeWorktreePath: '/home/dev', + repo: { path: '/repos/main' }, + runtimeRepoPath: '/repos/main', + registeredWorktrees: [makeGitWorktree('/repos/main', true)], + statPath: makeStatPath([], ['/home/dev']), + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).not.toHaveBeenCalled() + }) + + it('rejects unregistered leftover directories that still answer git status', async () => { + const isGitRepository = vi.fn().mockResolvedValue(true) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + statPath: makeStatPath([], ['/workspaces/orca-owned']), + isGitRepository + }) + ).resolves.toBe(false) + + expect(isGitRepository).toHaveBeenCalledWith('/workspaces/orca-owned') + }) + + it('rejects unregistered leftover directories that contain a registered child worktree', async () => { + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + registeredWorktrees: [ + makeGitWorktree(repo.path, true), + makeGitWorktree('/workspaces/orca-owned/child') + ], + statPath: makeStatPath([], ['/workspaces/orca-owned']), + isGitRepository: vi.fn().mockResolvedValue(false) + }) + ).rejects.toThrow( + 'Refusing to delete worktree because it contains another registered worktree: /workspaces/orca-owned/child' + ) + }) + + it('uses runtime paths for filesystem proof and original paths for nested worktree checks', async () => { + const statPath = vi.fn(async (path: string) => { + if (path === '/mnt/c/workspaces/orca-owned') { + return { type: 'directory' } + } + throw missingPath(path) + }) + const isGitRepository = vi.fn().mockResolvedValue(false) + + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + worktreePath: 'C:\\workspaces\\orca-owned', + runtimeWorktreePath: '/mnt/c/workspaces/orca-owned', + repo: { path: 'C:\\repos\\main' }, + runtimeRepoPath: '/mnt/c/repos/main', + registeredWorktrees: [ + makeGitWorktree('C:\\repos\\main', true), + makeGitWorktree('C:\\workspaces\\orca-owned-sibling') + ], + statPath, + isGitRepository + }) + ).resolves.toBe(true) + + expect(statPath).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned') + expect(statPath).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned/.git') + expect(statPath).not.toHaveBeenCalledWith('C:\\workspaces\\orca-owned') + expect(isGitRepository).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned') + }) + + it('rejects translated-runtime cleanup when original path contains a registered child', async () => { + await expect( + canCleanupUnregisteredOrcaLeftoverDirectory({ + ...baseArgs, + worktreePath: 'C:\\workspaces\\orca-owned', + runtimeWorktreePath: '/mnt/c/workspaces/orca-owned', + repo: { path: 'C:\\repos\\main' }, + runtimeRepoPath: '/mnt/c/repos/main', + registeredWorktrees: [ + makeGitWorktree('C:\\repos\\main', true), + makeGitWorktree('C:\\workspaces\\orca-owned\\child') + ], + statPath: makeStatPath([], ['/mnt/c/workspaces/orca-owned']), + isGitRepository: vi.fn().mockResolvedValue(false) + }) + ).rejects.toThrow( + 'Refusing to delete worktree because it contains another registered worktree: C:\\workspaces\\orca-owned\\child' + ) + }) }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 33fbe86fe..67206a88a 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -81,7 +81,23 @@ export function isDangerousWorktreeRemovalPath(worktreePath: string, repoPath: s } const homePath = homedir() - return !!homePath && containsPath(resolvedWorktreePath, pathOps.resolve(homePath), pathOps) + if (!!homePath && containsPath(resolvedWorktreePath, pathOps.resolve(homePath), pathOps)) { + return true + } + + return isLikelyPosixHomeDirectory(resolvedWorktreePath, pathOps) +} + +function isLikelyPosixHomeDirectory(resolvedWorktreePath: string, pathOps: PathOps): boolean { + if (pathOps !== posix) { + return false + } + return ( + resolvedWorktreePath === '/home' || + resolvedWorktreePath === '/root' || + /^\/home\/[^/]+$/.test(resolvedWorktreePath) || + /^\/Users\/[^/]+$/.test(resolvedWorktreePath) + ) } export function getRegisteredDeletableWorktree( @@ -173,6 +189,53 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) } +export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { + meta: UnregisteredOrcaCleanupMeta | null | undefined + worktreePath: string + runtimeWorktreePath: string + repo: Pick + runtimeRepoPath: string + knownOrcaLayouts: readonly OrcaWorkspaceLayout[] + registeredWorktrees: readonly GitWorktreeInfo[] + statPath: StatPath + isGitRepository: (runtimeWorktreePath: string) => Promise +}): Promise { + // Why: this recovery state has already lost the worktree .git marker, so the + // existing .git-file orphan proof cannot establish ownership. + // Why: without a surviving .git file, path shape alone is too weak to prove + // ownership for recursive deletion; require persisted Orca-created evidence. + if (!hasCurrentOrcaCreationProvenance(args.meta) && !hasLegacyOrcaCreationEvidence(args.meta)) { + return false + } + + if ( + isDangerousWorktreeRemovalPath(args.worktreePath, args.repo.path) || + isDangerousWorktreeRemovalPath(args.runtimeWorktreePath, args.runtimeRepoPath) + ) { + return false + } + + assertWorktreeDoesNotContainRegisteredWorktree(args.worktreePath, args.registeredWorktrees) + + const targetEntry = await args.statPath(args.runtimeWorktreePath).catch(() => null) + if (!isDirectoryStat(targetEntry)) { + return false + } + + const pathOps = getPathOps(args.runtimeWorktreePath, args.runtimeRepoPath) + const gitMarkerPath = pathOps.join(args.runtimeWorktreePath, '.git') + try { + await args.statPath(gitMarkerPath) + return false + } catch (error) { + if (!isMissingPathError(error)) { + return false + } + } + + return !(await args.isGitRepository(args.runtimeWorktreePath)) +} + function hasCurrentOrcaCreationProvenance( meta: Pick | null | undefined ): boolean { @@ -228,6 +291,20 @@ function isMissingPathError(error: unknown): boolean { ) } +function isDirectoryStat(stat: unknown): boolean { + const entry = + stat && typeof stat === 'object' + ? (stat as { isDirectory?: () => boolean; isSymbolicLink?: () => boolean; type?: unknown }) + : null + if (!entry) { + return false + } + if (entry.isSymbolicLink?.() === true || entry.type === 'symlink') { + return false + } + return entry.isDirectory?.() === true || entry.type === 'directory' +} + export async function isWorktreePathMissing( worktreePath: string, statPath: (path: string) => Promise = lstat