Recover partially deleted local worktrees (#6657)
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
parent
3481c1cb46
commit
206a70964e
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue