Recover partially deleted local worktrees (#6657)

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-29 00:53:07 -07:00 committed by GitHub
parent 3481c1cb46
commit 206a70964e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 691 additions and 22 deletions

View File

@ -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 = {

View File

@ -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<boolean> {
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

View File

@ -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<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
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'
})
})
})
})

View File

@ -73,6 +73,16 @@ function runWslLoginShellCommand(distro: string, command: string): Promise<ExecF
)
}
function isWslMissingPathError(error: unknown): boolean {
// Why: the WSL stat probe exits 2 for its explicit "missing path" branch;
// normalize that shell-specific result so callers can handle it like fs.lstat.
const code =
error && typeof error === 'object' && 'code' in error
? String((error as NodeJS.ErrnoException).code)
: ''
return code === '2'
}
export function toLocalWorktreeRuntimePath(
targetPath: string,
options: LocalWorktreeFilesystemOptions = {}
@ -100,7 +110,12 @@ export function getLocalWorktreePathAccess(
`target=${target}`,
'if [ -L "$target" ]; then printf symlink; elif [ -f "$target" ]; then printf file; elif [ -d "$target" ]; then printf directory; else exit 2; fi'
].join('\n')
)
).catch((error) => {
if (isWslMissingPathError(error)) {
throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' })
}
throw error
})
return { type: stdout.trim() }
},
readPath: async (path) => {

View File

@ -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 = {

View File

@ -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<boolean> {
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

View File

@ -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'
)
})
})

View File

@ -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<Repo, 'path'>
runtimeRepoPath: string
knownOrcaLayouts: readonly OrcaWorkspaceLayout[]
registeredWorktrees: readonly GitWorktreeInfo[]
statPath: StatPath
isGitRepository: (runtimeWorktreePath: string) => Promise<boolean>
}): Promise<boolean> {
// 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<WorktreeMeta, 'orcaCreatedAt' | 'orcaCreationSource'> | 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<unknown> = lstat