diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..4ba1f6f5164 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -42,7 +42,6 @@ inline src/main/ipc/pty.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts -inline src/main/ipc/worktrees.ts inline src/main/jira/issues.ts inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts new file mode 100644 index 00000000000..6b64dbe6a6f --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -0,0 +1,67 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:check', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo) { + const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) + // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. + return { + status: args.hostId || repoIdExists ? 'error' : 'ok', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + if (isFolderRepo(repo)) { + return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + return { + status: 'ok', + hasHooks: !result.isBinary, + hooks: result.isBinary ? null : parseOrcaYaml(result.content), + mayNeedUpdate: false + } + } catch (error) { + return { + status: isENOENT(error) ? 'ok' : 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". + const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + return { + status: 'ok', + hasHooks: has, + hooks, + mayNeedUpdate + } + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts new file mode 100644 index 00000000000..881a5e3bd0a --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { isENOENT } from '../filesystem-path-containment' +import { parseOrcaYaml } from '../../hooks' +import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:readIssueCommand', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return { + status: 'ok', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + + let status: 'ok' | 'error' = 'ok' + let localContent: string | null = null + let sharedContent: string | null = null + try { + const result = await fsProvider.readFile(issueCommandPath) + localContent = result.isBinary ? null : result.content.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + sharedContent = result.isBinary + ? null + : parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + const effectiveContent = localContent ?? sharedContent + return { + status: localContent ? 'ok' : status, + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + return readIssueCommand(repo.path) + } + ) + + ipcMain.handle( + 'hooks:writeIssueCommand', + async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + throw new Error( + 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' + ) + } + const trimmed = args.content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + await fsProvider.writeFile(gitignorePath, '.orca\n') + } + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return + } + writeIssueCommand(repo.path, args.content) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts new file mode 100644 index 00000000000..36dcfbc1288 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { readFile, stat } from 'node:fs/promises' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:inspectSetupScriptImports', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return [] + } + + return inspectSetupScriptImportCandidates( + async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return null + } + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content + } catch { + return null + } + } + + try { + return await readFile(filePath, 'utf-8') + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to inspect setup script import candidate:', error) + } + return null + } + }, + { + fileExists: async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + try { + const fileStat = await fsProvider.stat(filePath) + return fileStat.type !== 'directory' + } catch { + return false + } + } + + try { + const fileStat = await stat(filePath) + return !fileStat.isDirectory() + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to stat setup script import candidate:', error) + } + return false + } + } + } + ) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts new file mode 100644 index 00000000000..b6386616bf2 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts @@ -0,0 +1,29 @@ +import { ipcMain } from 'electron' +import { + createIssueCommandRunnerScript, + resolveSetupRunnerShell +} from '../../worktree-runner-script' +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookRunnerHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:createIssueCommandRunner', + (_event, args: { repoId: string; worktreePath: string; command: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + return createIssueCommandRunnerScript( + repo, + args.worktreePath, + args.command, + getLocalProjectWorktreeGitOptions(store, repo), + resolveSetupRunnerShell(store.getSettings()) + ) + } + ) +} diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 798cc443442..7e2ebac49f5 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { + handleMock, removeHandlerMock, listWorktreesMock, getPullRequestPushTargetMock, @@ -14,6 +15,35 @@ import { } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const + vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() ) @@ -113,6 +143,20 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined() }) + it('removes exactly the installed channel set before installing the first handler', () => { + const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel) + const installedChannels = handleMock.mock.calls.map(([channel]) => channel) + + expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(new Set(removedChannels)).toEqual(new Set(installedChannels)) + expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...handleMock.mock.invocationCallOrder) + ) + }) + it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => { const liveId = 'repo-1::/workspace/repo' const staleId = 'removed-repo::/workspace/gone' diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index 6fb798fad3f..3504fd3a0d6 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { notifyWorktreesChanged } from './worktree-remote' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees' +import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees/listing/detected-worktree-scan-cache' import { setPlatform, listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index b1616f6eecc..9bace029631 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -156,6 +156,22 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).not.toHaveBeenCalled() }) + it('purges metadata after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + it('sweeps a connected SSH owner through its PTY provider', async () => { const worktreeId = 'repo-gone::/workspace/feature-wt' const sshProvider = {} as never diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index cb12b0095f9..e109a13848f 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../shared/worktree/types' import { toSshExecutionHostId } from '../../shared/execution-host' -import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees' +import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cc10e1929a1..49dc7b633bd 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -137,6 +137,35 @@ describe('registerWorktreeHandlers', () => { ) }) + it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + const localRepo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + ...localRepo, + displayName: 'ssh', + connectionId: 'conn-1' + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + }) + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) @@ -484,6 +513,29 @@ describe('registerWorktreeHandlers', () => { }) }) + it('purges a folder workspace after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-folder' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + }) + // Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:` // — reusable across hosts — so the sweep must name the owning connection. it('fences an SSH folder workspace PTY sweep to the owning connection', async () => { diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index c801d0498f3..8c2d6b0edd7 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import { toSshExecutionHostId } from '../../shared/execution-host' -import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees' +import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index de0d3a3ed5d..4e1c524fa67 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -1,6 +1,7 @@ import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests } from './worktrees/listing/detected-worktree-scan-cache' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..5e70faa1871 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,1848 +1,53 @@ -/* oxlint-disable max-lines */ -import { app, ipcMain, type BrowserWindow } from 'electron' -import { readFile, stat } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import type { Store } from '../persistence' -import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' -import { isFolderRepo } from '../../shared/repo-kind' -import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' -import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' -import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context' -import { getProjectGroupSubtreeIds } from '../../shared/project-groups' -import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' -import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { - pruneWorkspaceCleanupScanSnapshot, - pruneWorkspaceCleanupScanSnapshots -} from '../workspace-cleanup-scan-snapshot' -import { - pruneWorkspaceSpaceAnalysisSnapshot, - pruneWorkspaceSpaceAnalysisSnapshots -} from '../workspace-space-analysis-snapshot' -import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' -import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' -import type { - AdoptProvisionedRootArgs, - CreateWorktreeArgs, - CreateWorktreeResult, - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { - AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - DetectedWorktree, - DetectedWorktreeListResult, - GitHubPrStartPoint, - GitPushTarget, - GitWorktreeInfo, - Worktree -} from '../../shared/worktree/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { - PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, - type DirectSshDetectedWorktreeRequest, - type ForgetRemovedWorktreesForExecutionHostArgs, - type ForgetRemovedWorktreesForExecutionHostResult, - type HostQualifiedKnownWorktreeResult, - type HostQualifiedDetectedWorktreeResult, - type ListKnownWorktreesForExecutionHostArgs, - type ListDetectedWorktreesArgs, - type ProviderRequestId -} from '../../shared/detected-worktree-provider-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' -import { - applyMetadataFallbackVisibility, - buildKnownOrcaWorkspaceLayouts, - isLegacyRepoForExternalWorktreeVisibility, - toDetectedWorktree -} from '../../shared/worktree/ownership' -import { - createWorktreeVisibilitySourceMatcher, - resolveCustomWorktreeVisibilitySources -} from '../../shared/worktree/visibility-sources' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - listWorktreesStrict as listGitWorktreesStrict, - removeWorktree -} from '../git/worktree' -import { gitExecFileAsync } from '../git/runner' -import { withWorktreeRemoveStageSpan, withWorktreeSpan } from '../observability/instrumentation' -import { resolveGitHubPrStartPoint } from '../github/pr-start-point' -import { - fetchGitHubPullRequestHeadRef, - fetchPrHeadTrackingRef -} from '../github/pr-head-tracking-ref' -import { pruneWorktreePRRefreshAliases } from '../github/pr-refresh-coordinator' -import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' -import { listRepoWorktrees } from '../repo-worktrees' -import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - getEffectiveHooks, - loadHooks, - parseOrcaYaml, - runHook, - hasHooksFile, - hasUnrecognizedOrcaYamlKeys -} from '../hooks' -import { createIssueCommandRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' -import { getSetupRunnerEnvVars } from '../setup-hook-env-vars' -import { getEffectiveHooksFromConfig } from '../effective-hook-config' -import { readIssueCommand, writeIssueCommand } from '../issue-command-file' -import { - mergeWorktree, - parseWorktreeId, - areWorktreePathsEqual, - formatWorktreeRemovalError, - isOrphanCompatiblePreflightError, - isOrphanedWorktreeError -} from './worktree-logic' -import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../shared/worktree/retired-name-registry' -import { dedupeWorktreesByPath } from './worktree-path-comparison' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' -import { - createLocalWorktree, - createRemoteWorktree, - cleanupUnusedWorktreePushTargetRemote, - cleanupUnusedWorktreePushTargetRemoteSsh, - notifyWorktreesChanged -} from './worktree-remote' -import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' -import { isENOENT } from './filesystem-path-containment' -import { - invalidateAuthorizedRootsCache, - registerWorktreeRootsForRepo -} from './registered-worktree-roots-cache' +import { ipcMain, type BrowserWindow } from 'electron' +import type { Store } from '../persistence/loading-store/store' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' -import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' -import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' -import { - finishAutomationWorkspaceProvenanceRequest, - releaseAutomationWorkspaceProvenanceRequest, - resolveAutomationWorkspaceProvenance -} from '../automations/workspace-provenance' -import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' -import { - getSshProviderAuthority, - isCurrentSshProviderAuthority, - registerSshProviderRequestAbort -} from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { adoptProvisionedRootSshCheckout } from '../provisioned-root-ssh-adoption' - -type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance -} - -type RemoveWorktreeArgs = { - worktreeId: string - hostId?: ExecutionHostId - force?: boolean - /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean - snapshotPruneBatchId?: string -} - -type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs - -async function stopPtysForDestructiveWorktreeRemoval( - runtime: OrcaRuntimeService, - worktreeId: string, - options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} -): Promise { - const { connectionId, allowUnverifiedStop } = options - const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() - if (!provider) { - throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) - } - const teardownResult = await killAllProcessesForWorktree(worktreeId, { - runtime, - // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id - // workspace's terminals on another connection — and the selector lookup this replaces - // throws `selector_ambiguous` the moment two hosts own the id. - resolvedWorktreeId: worktreeId, - ...(connectionId ? { resolvedConnectionId: connectionId } : {}), - localProvider: provider, - onPtyStopped: clearProviderPtyState, - requirePhysicalStop: true, - // Why (#11960): set only by an explicit Force Delete, never by the ordinary - // confirmation — otherwise the gate would be off on the primary delete path. - ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), - ...(connectionId ? { includeLocalRegistry: false } : {}) - }) - const total = - teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` - ) - } -} - -function getRepoForWorktreeRemoval( - store: Store, - repoId: string, - hostId?: ExecutionHostId -): Repo | undefined { - // Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner. - const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) - return owner.kind === 'resolved' ? owner.repo : undefined -} -import { - hasWorktreeRemovalRepoOwnerOnOtherHost, - resolveWorktreeRemovalMetadata, - resolveWorktreeRemovalRepoOwner -} from '../worktree-removal-repo-owner' -import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' -import { - assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, - canCleanupUnregisteredOrcaWorktreeDirectory, - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath, - isWorktreePathMissing, - ORPHANED_WORKTREE_DIRECTORY_MESSAGE, - stripOrcaProvenanceMetaUpdates, - UNREGISTERED_MISSING_WORKTREE_MESSAGE -} from '../worktree-removal-safety' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId, - getWorktreePathBasenameFromId -} from '../../shared/worktree/id' -import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { - getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions -} from '../project-runtime-git-options' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' -import { - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, - recoverLocalWindowsWorktreeRemoval -} from '../local-worktree-removal-recovery' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' - -const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() -const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() -const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 -const WORKTREE_LIST_ALL_CONCURRENCY = 8 - -function normalizeLinkedWorkItemFields< - T extends { - linkedWorkItem?: unknown - linkedTaskSourceContext?: unknown - } ->(input: T): T { - const linkedWorkItem = - input.linkedWorkItem === undefined - ? undefined - : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) - const linkedTaskSourceContext = - input.linkedTaskSourceContext === undefined - ? undefined - : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) - if ( - linkedWorkItem && - linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) - ) { - throw new Error('Linked work item and source context identities must match') - } - return { - ...input, - ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) - } -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} - -// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner -// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. -function resolveWorktreeRemovalOwnerHostId( - store: Store, - worktreeId: string, - repo: Repo | undefined, - fallbackHostId?: ExecutionHostId -): ExecutionHostId | undefined { - return ( - fallbackHostId ?? - (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) - ) -} - -function removeWorktreeMetadataAndTransientState( - store: Store, - worktreeId: string, - hostId?: ExecutionHostId, - snapshotPruneBatchId?: string -): void { - const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId - const repoId = getRepoIdFromWorktreeId(worktreeId) - const preservesSameIdOwner = Boolean( - hostId && - ((persistedHostId && persistedHostId !== hostId) || - hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) - ) - // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } - if (!preservesSameIdOwner) { - advertisedUrlWatcher.forgetWorktree(worktreeId) - // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. - localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) - // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. - deleteWorktreeHistoryDir(worktreeId) - // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). - pruneWorktreePRRefreshAliases(worktreeId) - } - // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. - const snapshotDirectory = store.getProfileStorageDirectory() - if (snapshotPruneBatchId) { - recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { - batchId: snapshotPruneBatchId, - worktreeId, - executionHostId: hostId - }) - return - } - void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) - void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) -} - -function getProjectHostSetupMetaUpdates( - store: Store, - repo: Repo, - existing?: WorktreeMeta -): Partial> { - const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - const sameSetup = - existing?.projectHostSetupId === undefined || - existing.projectHostSetupId === ownership.projectHostSetupId - return { - // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. - ...(sameSetup && existing?.projectId !== ownership.projectId - ? { projectId: ownership.projectId } - : {}), - ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), - ...(existing?.projectHostSetupId === undefined - ? { projectHostSetupId: ownership.projectHostSetupId } - : {}) - } -} - -// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. -function resolveWorktreeMetaWithDiscoveryBackfill( - store: Store, - repo: Repo, - worktreeId: string -): WorktreeMeta { - const existing = store.getWorktreeMeta(worktreeId) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - if (existing) { - const updates = { - ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), - ...ownershipUpdates - } - if (Object.keys(updates).length > 0) { - // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. - return store.setWorktreeMeta(worktreeId, updates) - } - return existing - } - return store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), - ...ownershipUpdates - }) -} - -async function isAlreadyRemovedWorktreePath( - repo: Repo, - worktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - return isWorktreePathMissing( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - access.statPath - ) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - 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 - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean -}): string { - const forceKey = args.force === true ? 'force' : 'normal' - const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' - // Why: a Force Delete retry must not coalesce onto the in-flight attempt that - // just failed the PTY gate — it would inherit that failure instead of retrying. - const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' - return `${forceKey}:${archiveKey}:${ptyKey}` -} - -function getWorktreeRemovalInFlightKey(worktreeId: string, hostId?: ExecutionHostId): string { - return `${hostId ?? ''}\0${worktreeId}` -} - -async function getArchiveHooksForRemoval(repo: Repo): Promise { - if (!repo.connectionId) { - return getEffectiveHooks(repo) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return getEffectiveHooksFromConfig(repo, null) - } - - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks) - } catch { - return getEffectiveHooksFromConfig(repo, null) - } -} - -async function runRemoteArchiveHook( - repo: Repo, - worktreePath: string, - script: string -): Promise<{ success: boolean; output: string }> { - if (!repo.connectionId) { - return { success: true, output: '' } - } - - const provider = requireSshGitProvider(repo.connectionId) - const env = getSetupRunnerEnvVars(repo, worktreePath) - const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) - const result = await provider - .execNonInteractive( - isWindowsRemote ? 'cmd.exe' : '/bin/bash', - isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], - worktreePath, - WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, - undefined, - env - ) - .catch((error) => ({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: error instanceof Error ? error.message : String(error) - })) - const output = [ - result.stdout, - result.stderr, - result.spawnError, - result.timedOut ? 'archive hook timed out' : null, - typeof result.exitCode === 'number' && result.exitCode !== 0 - ? `archive hook exited ${result.exitCode}` - : null - ] - .filter((part): part is string => Boolean(part)) - .join('\n') - .trim() - - return { - success: !result.spawnError && !result.timedOut && result.exitCode === 0, - output - } -} - -type WorktreeRemovalInFlight = { - optionsKey: string - promise: Promise -} - -type PreservedBranchCleanupTarget = { - worktreeId: string - hostId: ExecutionHostId - branchName: string - head: string - pushTarget?: GitPushTarget -} - -const preservedBranchCleanupByScope = new Map() - -function rememberPreservedBranchCleanupTarget( - worktreeId: string, - hostId: ExecutionHostId, - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined, - pushTarget: GitPushTarget | undefined -): void { - if (result?.preservedBranch) { - const head = result.preservedBranch.head ?? fallbackHead - if (!head) { - throw new Error( - `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` - ) - } - preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { - worktreeId, - hostId, - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) - return - } - preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) -} - -function preserveBranchHeadFallback( - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined -): RemoveWorktreeResult { - if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { - return result ?? {} - } - return { - ...result, - preservedBranch: { - ...result.preservedBranch, - head: fallbackHead - } - } -} - -function getPreservedBranchCleanupTarget( - worktreeId: string, - branchName: string, - expectedHead: string, - hostId?: ExecutionHostId -): PreservedBranchCleanupTarget { - const exactTarget = hostId - ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) - : undefined - const legacyMatches = hostId - ? [] - : [...preservedBranchCleanupByScope.values()].filter( - (target) => - target.worktreeId === worktreeId && - target.branchName === branchName && - target.head === expectedHead - ) - const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) - if (!target || target.branchName !== branchName || target.head !== expectedHead) { - throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) - } - return target -} - -const loggedUnavailableSshGitProviders = new Set() -const loggedWorktreeListFailures = new Set() -const loggedMalformedWorktreeMetaKeys = new Set() -export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 -export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 -// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. -const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 - -type DetectedWorktreeScanCacheEntry = { - expiresAt: number - worktrees: GitWorktreeInfo[] -} - -type DetectedWorktreeScan = { - invalidated: boolean - promise: Promise -} - -type DetectedWorktreeScanResult = { - gitWorktrees: GitWorktreeInfo[] - fresh: boolean -} - -const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map() - -function invalidateDetectedWorktreeScanCache(repoId: string): void { - const keyPrefix = `${repoId}\0` - for (const key of new Set([ - ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys() - ])) { - if (!key.startsWith(keyPrefix)) { - continue - } - detectedWorktreeScanCache.delete(key) - const inFlight = detectedWorktreeScanInFlight.get(key) - if (inFlight) { - // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. - inFlight.invalidated = true - detectedWorktreeScanInFlight.delete(key) - } - } -} - -registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) - -export function __resetDetectedWorktreeScanCacheForTests(): void { - // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. - for (const scan of detectedWorktreeScanInFlight.values()) { - scan.invalidated = true - } - detectedWorktreeScanCache.clear() - detectedWorktreeScanInFlight.clear() -} - -export function __getDetectedWorktreeScanCacheStatsForTests(): { - cacheSize: number - inFlightSize: number -} { - return { - cacheSize: detectedWorktreeScanCache.size, - inFlightSize: detectedWorktreeScanInFlight.size - } -} - -async function listDetectedGitWorktrees( - store: Store, - repo: Repo -): Promise { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - if (repo.connectionId || isFolderRepo(repo)) { - return { - gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), - fresh: true - } - } - - const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) - const cached = detectedWorktreeScanCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return { gitWorktrees: cached.worktrees, fresh: false } - } - - const inFlight = detectedWorktreeScanInFlight.get(cacheKey) - if (inFlight) { - return { gitWorktrees: await inFlight.promise, fresh: false } - } - - const scan: DetectedWorktreeScan = { - invalidated: false, - promise: listRepoWorktrees(repo, localWorktreeGitOptions) - } - detectedWorktreeScanInFlight.set(cacheKey, scan) - try { - const gitWorktrees = await scan.promise - // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. - if (!scan.invalidated) { - detectedWorktreeScanCache.set(cacheKey, { - worktrees: gitWorktrees, - expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS - }) - } - return { gitWorktrees, fresh: !scan.invalidated } - } finally { - if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { - detectedWorktreeScanInFlight.delete(cacheKey) - } - } -} - -function getDetectedWorktreeScanCacheKey( - repoId: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): string { - return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` -} - -function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { - if (!shouldEmitBoundedWarning(keySet, key)) { - return - } - if (error) { - console.warn(message, error) - } else { - console.warn(message) - } -} - -function rememberLocalWorktreeRoots( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): void { - if (repo.connectionId) { - return - } - // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo.id, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) -} - -type SshWorktreeMetaCandidate = { - id: string - path: string - meta: WorktreeMeta -} - -type SshWorktreeMetaIndex = Map - -function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorktreeMetaIndex { - const index: SshWorktreeMetaIndex = new Map() - for (const [worktreeId, meta] of entries) { - let parsed: { repoId: string; worktreePath: string } - try { - parsed = parseWorktreeId(worktreeId) - } catch (err) { - warnOnce( - loggedMalformedWorktreeMetaKeys, - worktreeId, - `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, - err - ) - continue - } - - const candidates = index.get(parsed.repoId) ?? [] - candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) - index.set(parsed.repoId, candidates) - } - return index -} - -// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole -// store, so this is cheaper per call than the unfiltered index, not free. -function createSshWorktreeMetaIndexForRepo( - allMeta: Record, - repoId: string -): SshWorktreeMetaIndex { - return createSshWorktreeMetaIndex( - Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) - ) -} - -function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { - return { - path, - head: '', - branch: '', - isBare: false, - isMainWorktree: areWorktreePathsEqual(path, repo.path), - ...(meta.sparseDirectories !== undefined || - meta.sparseBaseRef !== undefined || - meta.sparsePresetId !== undefined - ? { isSparse: true } - : {}) - } -} - -function listDisconnectedSshWorktrees( - store: Store, - repo: Repo, - metaIndex: SshWorktreeMetaIndex -): ReturnType[] { - const byWorktreeId = new Map>() - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) - for (const candidate of metaIndex.get(repo.id) ?? []) { - if ( - (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || - (!candidate.meta.hostId && repoOwners.length > 1) - ) { - continue - } - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) - const meta = - Object.keys(ownershipUpdates).length > 0 - ? { ...candidate.meta, ...ownershipUpdates } - : candidate.meta - if (Object.keys(ownershipUpdates).length > 0) { - store.setWorktreeMeta(candidate.id, ownershipUpdates) - } - // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() - // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must - // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic - // basename instead. - const worktree = mergeWorktree( - repo.id, - synthesizeSshGitWorktree(repo, candidate.path, meta), - meta, - getWorktreePathBasenameFromId(candidate.id) ?? undefined - ) - byWorktreeId.delete(worktree.id) - byWorktreeId.set(worktree.id, worktree) - } - return [...byWorktreeId.values()] -} - -function buildDetectedGitWorktrees( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): DetectedWorktree[] { - const settings = store.getSettings() - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) - const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. - const liveWorktrees = dedupeWorktreesByPath( - gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) - ) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = liveWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - let meta = store.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - if (!detected.visible) { - return detected - } - - meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) - return toDetectedWorktree({ - repo, - worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function stampAndMergeVisibleDetectedWorktree( - store: Store, - repo: Repo, - detected: DetectedWorktree -) { - const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) - return mergeWorktree(repo.id, detected, meta, repo.displayName) -} - -function getFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` -} - -function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { - return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` -} - -function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { - const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() -} - -function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { - const rootId = getFolderWorkspaceRootId(repo) - return ( - worktreeId === rootId || - worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) - ) -} - -function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { - return { - id: worktreeId, - ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId: repo.id, - ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), - displayName: meta.displayName || repo.displayName, - comment: meta.comment || '', - linkedIssue: meta.linkedIssue ?? null, - linkedPR: meta.linkedPR ?? null, - linkedLinearIssue: meta.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, - linkedGitLabMR: meta.linkedGitLabMR ?? null, - linkedGitLabIssue: meta.linkedGitLabIssue ?? null, - linkedBitbucketPR: meta.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: meta.linkedGiteaPR ?? null, - linkedWorkItem: meta.linkedWorkItem ?? null, - linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, - isArchived: meta.isArchived ?? false, - isUnread: meta.isUnread ?? false, - isPinned: meta.isPinned ?? false, - sortOrder: meta.sortOrder ?? 0, - ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta.lastActivityAt ?? 0, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta.automationProvenance !== undefined - ? { automationProvenance: meta.automationProvenance } - : {}), - ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), - ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), - workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview - } -} - -function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - const rootId = getFolderWorkspaceRootId(repo) - const allMeta = store.getAllWorktreeMeta() - const ids = Object.keys(allMeta).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (!ids.includes(rootId)) { - ids.unshift(rootId) - } - - return ids - .map((worktreeId) => { - const existing = allMeta[worktreeId] - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - const meta = - existing?.instanceId && Object.keys(ownershipUpdates).length === 0 - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: - existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...ownershipUpdates, - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) - return mergeFolderWorkspace(repo, worktreeId, meta) - }) - .sort((a, b) => { - if (a.id === rootId) { - return -1 - } - if (b.id === rootId) { - return 1 - } - return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) - }) -} - -function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { - const settings = store.getSettings() - const worktrees = listFolderWorkspaces(store, repo) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - return worktrees.map((worktree) => - toDetectedWorktree({ - repo, - worktree, - meta: store.getWorktreeMeta(worktree.id), - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - ) -} - -function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - return buildFolderDetectedWorktrees(store, repo) - .filter((worktree) => worktree.visible) - .map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) - const repairedMeta = - meta && Object.keys(ownershipUpdates).length === 0 - ? meta - : store.setWorktreeMeta(worktree.id, ownershipUpdates) - return mergeFolderWorkspace(repo, worktree.id, repairedMeta) - }) -} - -function createFolderWorkspace( - args: CreateWorktreeArgsWithSystemProvenance, - repo: Repo, - store: Store -): CreateWorktreeResult { - const now = Date.now() - const instanceId = randomUUID() - const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) - const meta = store.setWorktreeMeta(worktreeId, { - instanceId, - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - displayName: args.displayName || args.name, - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'desktop', - creatorProvenance: { kind: 'host' }, - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), - ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), - ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - }) - return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } -} - -function buildDisconnectedDetectedWorktrees( - store: Store, - repo: Repo, - worktrees: Worktree[] -): DetectedWorktree[] { - const settings = store.getSettings() - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = worktrees.map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - return applyMetadataFallbackVisibility(detected) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function hasConflictingStoredWorktreeOwner( - store: Store, - repo: Repo, - worktreeIds: readonly string[] -): boolean { - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - return worktreeIds.some((worktreeId) => { - const meta = store.getWorktreeMeta(worktreeId) - return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) - }) -} - -type RepoOwnershipEvidence = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'malformed' } - | { status: 'contradictory' } - -function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { - const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined - const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null - if (hasExplicitHost && !explicitHost) { - return { status: 'malformed' } - } - const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined - const connectionId = hasConnection ? repo.connectionId?.trim() : null - if (hasConnection && !connectionId) { - return { status: 'malformed' } - } - const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null - if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { - return { status: 'contradictory' } - } - return { - status: 'owned', - hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID - } -} - -function findExactRepoOwner( - store: Store, - repoId: string, - executionHostId?: ExecutionHostId -): Repo | undefined { - const candidates = store.getRepos().filter((repo) => repo.id === repoId) - const evidence = candidates.map(resolveRepoOwnershipEvidence) - if (evidence.some((owner) => owner.status !== 'owned')) { - return undefined - } - const matches = candidates.filter((_, index) => { - const owner = evidence[index] - return ( - owner?.status === 'owned' && - (executionHostId === undefined || owner.hostId === executionHostId) - ) - }) - return matches.length === 1 ? matches[0] : undefined -} - -function isCapturedRepoCurrent( - store: Store, - repo: Repo, - executionHostId?: ExecutionHostId -): boolean { - const current = findExactRepoOwner(store, repo.id, executionHostId) - return ( - current !== undefined && - current.path === repo.path && - (current.connectionId ?? null) === (repo.connectionId ?? null) && - (current.executionHostId ?? null) === (repo.executionHostId ?? null) - ) -} - -async function listDetectedWorktreesForCapturedRepo( - store: Store, - repo: Repo, - isCurrent: () => boolean, - capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const abortedResult = () => - providerAbort?.signal.aborted - ? ({ providerAbortStatus: providerAbort.status() } as const) - : undefined - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees: GitWorktreeInfo[] - let freshScan = true - if (isFolderRepo(repo)) { - if (!isCurrent()) { - return null - } - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - } - } - if (repo.connectionId) { - if (!capturedProvider) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - gitWorktrees = await capturedProvider.listWorktrees(repo.path, { - signal: providerAbort?.signal - }) - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) - if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) - } - } catch (err) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } - } -} - -function hasValidDirectSshAuthority( - args: DirectSshDetectedWorktreeRequest -): args is DirectSshDetectedWorktreeRequest { - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function hasValidLineageSshAuthority( - args: ListDesktopLineageForHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -type LineageOwner = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'ambiguous' | 'contradictory' | 'runtime' } - -type LineageFolder = ReturnType[number] -type LineageGroup = ReturnType[number] - -type LineageResolutionContext = { - store: Store - repos: Repo[] - groups: LineageGroup[] - reposById: Map - foldersById: Map - groupsById: Map - groupSubtreeIdsByRoot: Map> - worktreeOwners: Map - folderOwners: Map - workspaceOwners: Map -} - -function indexLineageEntriesById( - entries: readonly T[] -): Map { - const index = new Map() - for (const entry of entries) { - const matching = index.get(entry.id) ?? [] - matching.push(entry) - index.set(entry.id, matching) - } - return index -} - -function createLineageResolutionContext(store: Store): LineageResolutionContext { - const repos = store.getRepos() - const folders = store.getFolderWorkspaces() - const groups = store.getProjectGroups() - return { - store, - repos, - groups, - reposById: indexLineageEntriesById(repos), - foldersById: indexLineageEntriesById(folders), - groupsById: indexLineageEntriesById(groups), - groupSubtreeIdsByRoot: new Map(), - worktreeOwners: new Map(), - folderOwners: new Map(), - workspaceOwners: new Map() - } -} - -function resolveRepoLineageOwner(repo: Repo): LineageOwner { - const owner = resolveRepoOwnershipEvidence(repo) - if (owner.status === 'malformed') { - return { status: 'ambiguous' } - } - if (owner.status === 'contradictory') { - return { status: 'contradictory' } - } - return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner -} - -function resolveWorktreeLineageOwner( - context: LineageResolutionContext, - worktreeId: string -): LineageOwner { - const cached = context.worktreeOwners.get(worktreeId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.worktreeOwners.set(worktreeId, owner) - return owner - } - let repoId: string - try { - repoId = parseWorktreeId(worktreeId).repoId - } catch { - return remember({ status: 'ambiguous' }) - } - const repos = context.reposById.get(repoId) ?? [] - const meta = context.store.getWorktreeMeta(worktreeId) - const runtimeOwnerEnvironmentId = ( - meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined - )?.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwnerEnvironmentId) { - return remember({ status: 'runtime' }) - } - if (meta?.hostId) { - const explicitHost = parseExecutionHostId(meta.hostId) - if (!explicitHost) { - return remember({ status: 'ambiguous' }) - } - if (explicitHost.kind === 'runtime') { - return remember({ status: 'runtime' }) - } - const matchingRepos = repos.filter((repo) => { - const owner = resolveRepoLineageOwner(repo) - return owner.status === 'owned' && owner.hostId === explicitHost.id - }) - if (matchingRepos.length === 1) { - return remember({ status: 'owned', hostId: explicitHost.id }) - } - return remember( - matchingRepos.length > 1 - ? { status: 'ambiguous' } - : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } - ) - } - if (repos.length !== 1) { - return remember({ status: 'ambiguous' }) - } - return remember(resolveRepoLineageOwner(repos[0])) -} - -function getFolderLineageCandidateRepos( - context: LineageResolutionContext, - folder: LineageFolder -): Repo[] { - let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) - if (!groupIds) { - groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) - context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) - } - const grouped = context.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = context.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - const group = context.groupsById.get(folder.projectGroupId)?.[0] - const connectionId = folder.connectionId ?? group?.connectionId ?? null - return connectionId - ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - : grouped.length > 0 - ? [ - ...grouped, - ...pathRepos.filter((repo) => - new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( - repo.connectionId ?? null - ) - ) - ] - : pathRepos -} - -function resolveFolderLineageOwner( - context: LineageResolutionContext, - folderWorkspaceId: string -): LineageOwner { - const cached = context.folderOwners.get(folderWorkspaceId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.folderOwners.set(folderWorkspaceId, owner) - return owner - } - const folders = context.foldersById.get(folderWorkspaceId) ?? [] - if (folders.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const folder = folders[0] - const groups = context.groupsById.get(folder.projectGroupId) ?? [] - if (groups.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const group = groups[0] - const hosts = new Set() - if (folder.connectionId) { - hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) - } - if (group.connectionId) { - hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) - } - if (group.executionHostId) { - const parsed = parseExecutionHostId(group.executionHostId) - if (!parsed) { - return remember({ status: 'ambiguous' }) - } - hosts.add(parsed.id) - } - for (const repo of getFolderLineageCandidateRepos(context, folder)) { - const owner = resolveRepoLineageOwner(repo) - if (owner.status !== 'owned') { - return remember(owner) - } - hosts.add(owner.hostId) - } - if (hosts.size > 1) { - return remember({ status: 'contradictory' }) - } - const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID - return remember( - parseExecutionHostId(hostId)?.kind === 'runtime' - ? { status: 'runtime' } - : { status: 'owned', hostId } - ) -} - -function resolveWorkspaceLineageOwner( - context: LineageResolutionContext, - workspaceKey: string -): LineageOwner { - const cached = context.workspaceOwners.get(workspaceKey) - if (cached) { - return cached - } - const workspace = parseWorkspaceKey(workspaceKey) - const owner = !workspace - ? { status: 'ambiguous' as const } - : workspace.type === 'worktree' - ? resolveWorktreeLineageOwner(context, workspace.worktreeId) - : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) - context.workspaceOwners.set(workspaceKey, owner) - return owner -} - -function filterLineageForHost( - store: Store, - executionHostId: ExecutionHostId -): { - worktreeLineageById: Record - workspaceLineageByChildKey: Record -} | null { - const context = createLineageResolutionContext(store) - const worktreeLineageById: Record = {} - const workspaceLineageByChildKey: Record = {} - for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - const child = resolveWorktreeLineageOwner(context, worktreeId) - const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - worktreeLineageById[worktreeId] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { - const child = resolveWorkspaceLineageOwner(context, childKey) - const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - workspaceLineageByChildKey[childKey] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - return { worktreeLineageById, workspaceLineageByChildKey } -} - -async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { - let timeout: ReturnType | undefined - const hydration = Promise.resolve() - .then(() => runtime.hydrateInferredWorktreeLineage()) - .then( - () => true, - () => false - ) - const deadline = new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) - }) - try { - return await Promise.race([hydration, deadline]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -async function listDesktopLineageForHost( - store: Store, - runtime: OrcaRuntimeService, - args: ListDesktopLineageForHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostLineageSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - let provider: ReturnType | undefined - let authority: - | Extract['expectedAuthority'] - | null = null - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - } else { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - if ( - parsedHost.kind === 'ssh' && - (!authority || - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority)) - ) { - return rejected('stale') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - if (parsedHost.kind === 'local') { - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } - } - if (!authority) { - return rejected('authority-unknown') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - ...lineage - } -} - -async function listHostQualifiedDetectedWorktrees( - store: Store, - args: ListDetectedWorktreesArgs, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const parsedHost = parseExecutionHostId(args.executionHostId) - const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status - }) - if ( - typeof args.providerRequestId !== 'string' || - args.providerRequestId.length === 0 || - Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || - !parsedHost || - parsedHost.kind === 'runtime' - ) { - return rejected('rejected') - } - let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null - if (parsedHost.kind === 'ssh') { - const directArgs = args as DirectSshDetectedWorktreeRequest - if ( - !hasValidDirectSshAuthority(directArgs) || - directArgs.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - capturedAuthority = { ...directArgs.expectedAuthority } - if (!isCurrentSshProviderAuthority(capturedAuthority)) { - return rejected('stale') - } - } - - const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) - if ( - repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') - ) { - return rejected('rejected') - } - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo) { - return rejected('ambiguous-owner') - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return rejected('rejected') - } - const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined - const isCurrent = (): boolean => { - if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { - return false - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return false - } - if (parsedHost.kind !== 'ssh') { - return true - } - return ( - capturedAuthority !== null && - getSshGitProvider(parsedHost.targetId) === provider && - isCurrentSshProviderAuthority(capturedAuthority) - ) - } - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - isCurrent, - provider, - providerAbort - ) - if (!result) { - return rejected('stale') - } - if ('providerAbortStatus' in result) { - return { - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: result.providerAbortStatus - } - } - const status = result.authoritative ? 'complete' : 'non-authoritative' - if (parsedHost.kind === 'local') { - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result - } - } - if (!capturedAuthority) { - return rejected('rejected') - } - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { - kind: 'direct-ssh', - executionHostId: args.executionHostId as `ssh:${string}`, - ...capturedAuthority - }, - result - } -} +import { registerWorktreeCreateHandlers } from './worktrees/create/register-worktree-create-handlers' +import { registerWorktreePrefetchHandler } from './worktrees/create/register-worktree-prefetch-handler' +import { registerReviewBaseHandlers } from './worktrees/create/register-review-base-handlers' +import { registerWorktreeHookCheckHandler } from './hooks/register-worktree-hook-check-handler' +import { registerWorktreeHookFileHandlers } from './hooks/register-worktree-hook-file-handlers' +import { registerWorktreeHookInspectionHandler } from './hooks/register-worktree-hook-inspection-handler' +import { registerWorktreeHookRunnerHandler } from './hooks/register-worktree-hook-runner-handler' +import { registerDetectedWorktreeHandlers } from './worktrees/listing/register-detected-worktree-handlers' +import { registerHostCatalogHandlers } from './worktrees/listing/register-host-catalog-handlers' +import { registerWorktreeCatalogHandlers } from './worktrees/listing/register-worktree-catalog-handlers' +import { registerDetectedWorktreeScanInvalidation } from './worktrees/listing/register-detected-worktree-scan-invalidation' +import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' +import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' +import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' +import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' + +registerDetectedWorktreeScanInvalidation() + +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const export function registerWorktreeHandlers( mainWindow: BrowserWindow, @@ -1850,1826 +55,31 @@ export function registerWorktreeHandlers( runtime: OrcaRuntimeService, options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { - const detectedWorktreeCancellations = createSenderScopedRequestCancellations() - // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. - ipcMain.removeHandler('worktrees:listAll') - ipcMain.removeHandler('worktrees:list') - ipcMain.removeHandler('worktrees:listRetiredNames') - ipcMain.removeHandler('worktrees:listDetected') - ipcMain.removeHandler('worktrees:listKnownForExecutionHost') - ipcMain.removeHandler('worktrees:forgetRemovedForExecutionHost') - ipcMain.removeHandler('worktrees:cancelListDetected') - ipcMain.removeHandler('worktrees:create') - ipcMain.removeHandler('worktrees:adoptProvisionedRoot') - ipcMain.removeHandler('worktrees:prefetchCreateBase') - ipcMain.removeHandler('worktrees:resolvePrBase') - ipcMain.removeHandler('worktrees:resolveMrBase') - ipcMain.removeHandler('worktrees:remove') - ipcMain.removeHandler('worktrees:forgetLocal') - ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') - ipcMain.removeHandler('worktrees:updateMeta') - ipcMain.removeHandler('worktrees:listLineage') - ipcMain.removeHandler('worktrees:listLineageForHost') - ipcMain.removeHandler('worktrees:updateLineage') - ipcMain.removeHandler('worktrees:persistSortOrder') - ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') - ipcMain.removeHandler('hooks:check') - ipcMain.removeHandler('hooks:inspectSetupScriptImports') - ipcMain.removeHandler('hooks:createIssueCommandRunner') - ipcMain.removeHandler('hooks:readIssueCommand') - ipcMain.removeHandler('hooks:writeIssueCommand') + const context: WorktreeIpcContext = { + mainWindow, + store, + runtime, + ...(options ? { options } : {}), + detectedWorktreeCancellations: createSenderScopedRequestCancellations(), + worktreeRemovalsInFlight: new Map() + } - ipcMain.handle('worktrees:listAll', async () => { - const repos = store.getRepos() - const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() + // Remove all stale registrations before installing any replacement handler. + for (const channel of WORKTREE_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } - // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. - const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. - return [] - } - }) - - return results.flat() - }) - - ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return EMPTY_RETIRED_NAME_REGISTRY - } - return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) - }) - - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return [] - } - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. - return [] - } - }) - - ipcMain.handle( - 'worktrees:listKnownForExecutionHost', - (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { - // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — - // it owns no repo, so every guard below still rejects it. - const requestedRepoId = args?.repoId ?? '' - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const rejected = (): HostQualifiedKnownWorktreeResult => ({ - status: 'rejected', - repoId: requestedRepoId, - executionHostId: requestedExecutionHostId - }) - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh') { - return rejected() - } - // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the - // whole catalog, so a separate pass here is pure cost. - const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return rejected() - } - const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ - status: 'complete', - repoId: repo.id, - executionHostId: requestedExecutionHostId, - result: { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees - } - }) - // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. - if (isFolderRepo(repo)) { - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) - ? rejected() - : complete( - // Why: match the authoritative folder listing; without lineage these rows render flat and then - // reshuffle once the real scan lands. - projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - ) - } - const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) - return complete( - buildDisconnectedDetectedWorktrees( - store, - repo, - listDisconnectedSshWorktrees(store, repo, metaIndex) - ) - ) - } - ) - - // Why: gcStaleWorktreeMeta cannot stat a remote path, so SSH metadata outlives the worktree and the fallback - // above re-lists a worktree deleted outside Orca on every launch. An authoritative scan is the only proof of - // absence, so the renderer reports what it retired here and the row is dropped like a local GC would. - ipcMain.handle( - 'worktrees:forgetRemovedForExecutionHost', - ( - _event, - args: ForgetRemovedWorktreesForExecutionHostArgs - ): ForgetRemovedWorktreesForExecutionHostResult => { - const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { - forgottenWorktreeIds: [] - } - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { - return nothingForgotten - } - const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return nothingForgotten - } - // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips - // those keys for the same reason, and no remote scan can retire one. - if (isFolderRepo(repo)) { - return nothingForgotten - } - const allMeta = store.getAllWorktreeMeta() - const forgottenWorktreeIds: string[] = [] - for (const worktreeId of worktreeIds) { - const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined - if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { - continue - } - // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. - if (meta.hostId && meta.hostId !== requestedExecutionHostId) { - continue - } - store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) - forgottenWorktreeIds.push(worktreeId) - } - if (forgottenWorktreeIds.length > 0) { - const snapshotDirectory = store.getProfileStorageDirectory() - const targets = forgottenWorktreeIds.map((worktreeId) => ({ - worktreeId, - executionHostId: requestedExecutionHostId - })) - void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) - void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) - } - return { forgottenWorktreeIds } - } - ) - - ipcMain.handle( - 'worktrees:listDetected', - async ( - event, - args: DetectedWorktreeRequestArgs - ): Promise => { - if ('executionHostId' in args) { - const parsedHost = parseExecutionHostId(args.executionHostId) - const directSshRequest = parsedHost?.kind === 'ssh' - const controller = directSshRequest - ? detectedWorktreeCancellations.begin(event, args.providerRequestId) - : null - const directArgs = args as DirectSshDetectedWorktreeRequest - const removeAuthorityAbort = - controller && - parsedHost?.kind === 'ssh' && - hasValidDirectSshAuthority(directArgs) && - directArgs.expectedAuthority.targetId === parsedHost.targetId - ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) - : undefined - let timedOut = false - let removeAbortListener: (() => void) | undefined - const abortedResult = controller - ? new Promise((resolve) => { - const onAbort = (): void => { - resolve({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: timedOut ? 'timed-out' : 'canceled' - }) - } - controller.signal.addEventListener('abort', onAbort, { once: true }) - removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) - }) - : undefined - const timeout = controller - ? setTimeout(() => { - timedOut = true - controller.abort() - }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) - : undefined - try { - const providerResult = listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - return abortedResult - ? await Promise.race([providerResult, abortedResult]) - : await providerResult - } finally { - if (timeout) { - clearTimeout(timeout) - } - removeAbortListener?.() - removeAuthorityAbort?.() - detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) - } - } - const repo = findExactRepoOwner(store, args.repoId) - if (!repo) { - return { - repoId: args.repoId, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined - const authority = repo.connectionId - ? { ...getSshProviderAuthority(repo.connectionId) } - : undefined - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!repo.connectionId || - (getSshGitProvider(repo.connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - return result && !('providerAbortStatus' in result) - ? result - : { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - ) - ipcMain.handle( - 'worktrees:cancelListDetected', - (event, args: { providerRequestId: ProviderRequestId }): void => { - detectedWorktreeCancellations.cancel(event, args.providerRequestId) - } - ) - - ipcMain.handle( - 'worktrees:prefetchCreateBase', - async (_event, args: { repoId: string; baseBranch?: string }): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return - } - try { - await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) - } catch { - // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. - } - } - ) - - ipcMain.handle( - 'worktrees:create', - async (_event, rawArgs: CreateWorktreeArgs): Promise => { - const args = normalizeLinkedWorkItemFields(rawArgs) - // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - const createArgs: CreateWorktreeArgsWithSystemProvenance = { - ...args, - automationProvenance - } - - let result: CreateWorktreeResult - try { - // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - - // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. - track('workspace_created', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) - - if (isFolderRepo(repo)) { - notifyWorktreesChanged(mainWindow, repo.id) - } - - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:adoptProvisionedRoot', - async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { - const args = normalizeLinkedWorkItemFields(rawArgs) - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo || isFolderRepo(repo)) { - throw new Error('Provisioned-root repository ownership is missing or ambiguous.') - } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - let result: CreateWorktreeResult - try { - result = await adoptProvisionedRootSshCheckout({ - userDataPath: app.getPath('userData'), - request: { ...args, automationProvenance }, - repo, - store, - isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) - }) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_created', { - source, - from_existing_branch: false, - ...getCohortAtEmit() - }) - notifyWorktreesChanged(mainWindow, repo.id) - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:resolvePrBase', - async ( - _event, - args: { - repoId: string - prNumber: number - headRefName?: string - baseRefName?: string - isCrossRepository?: boolean - } - ): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return { error: 'Repo not found' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { - if (!repo.connectionId) { - return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) - } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error( - 'SSH Git provider is not available. Reconnect to this target and try again.' - ) - } - return provider.exec(args, repo.path) - } - // Why: SSH review-head fetches require narrow write-capable RPCs. - const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - branch, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - prNumber, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - - return resolveGitHubPrStartPoint({ - repoPath: repo.path, - prNumber: args.prNumber, - headRefName: args.headRefName, - baseRefName: args.baseRefName, - isCrossRepository: args.isCrossRepository, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef, - // Why: one resolver keeps source preference and hosting identity aligned - // across local, WSL, and SSH worktree creation. - resolveRemote: () => - resolveGitHubReviewHeadRemote({ - repoPath: repo.path, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec - }) - }) - } - ) - - // Why: keep desktop IPC and mobile/runtime RPC on the same MR-base path so SSH repos don't regress differently per surface. - ipcMain.handle( - 'worktrees:resolveMrBase', - async ( - _event, - args: { - repoId: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - } - ): Promise< - | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } - | { error: string } - > => { - return runtime.resolveManagedMrBase({ - repoSelector: `id:${args.repoId}`, - mrIid: args.mrIid, - sourceBranch: args.sourceBranch, - targetBranch: args.targetBranch, - isCrossRepository: args.isCrossRepository - }) - } - ) - - const worktreeRemovalsInFlight = new Map() - - ipcMain.handle( - 'worktrees:remove', - async (_event, args: RemoveWorktreeArgs): Promise => { - const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) - const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - // The resolved repo supplies host ownership when legacy callers omit args.hostId. - const removalHostId = getRepoExecutionHostId(repo) - const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) - const optionsKey = getWorktreeRemovalOptionsKey(args) - const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlightRemoval) { - if (inFlightRemoval.optionsKey === optionsKey) { - return inFlightRemoval.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. - const removal = (async (): Promise => { - // Why: worktree.create is traced; delete freezes were invisible without a matching worktree.remove parent span. - return withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - if (isFolderRepo(repo)) { - if (args.worktreeId === getFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - const ownerHost = parseExecutionHostId(removalHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. - await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { - // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly - // like the git paths — the local inventory must never reach a remote workspace's id. - // The resolved repo is authoritative here: path-derived metadata is shared by - // same-id host copies and can describe a different owner's workspace. - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: - ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - }) - await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - - // Why: renderer-supplied worktreeId embeds a path; re-derive the canonical path from git before any destructive action. - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const removedMeta = resolveWorktreeRemovalMetadata( - store, - repoId, - args.worktreeId, - removalHostId - ) - const removedPushTarget = removedMeta?.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - worktreePath, - registeredWorktrees - ) - if (!registeredWorktree) { - const fsProvider = repo.connectionId - ? getSshFilesystemProvider(repo.connectionId) - : null - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta - }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - worktreePath, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - const removalGate = await runtime.acquireFileWatcherRemoval( - worktreePath, - repo.connectionId - ) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: repo.connectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await fsProvider!.deletePath(worktreePath, true) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - 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), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - } - if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { - if (!args.force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. - if (repo.connectionId) { - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - - // Why: a Git lock must block before archive hooks or linked-path cleanup mutate the workspace; dirty-file force is separate. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: a prior forced Windows recovery can delete the dir but leave a stale Git registration; verify before clearing metadata. - if ( - !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath( - repo, - canonicalWorktreePath, - localWorktreeGitOptions - )) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - // Run archive hook before removal so teardown scripts still see the worktree directory. - const hooks = await getArchiveHooksForRemoval(repo) - const archiveScript = hooks?.scripts.archive - if (archiveScript && !args.skipArchive) { - // Why the branch on connectionId: this block is shared by both flows, so a hardcoded - // 'remote' would file every local archive hook under the SSH breakdown. - await withWorktreeRemoveStageSpan( - 'archive_hook', - repo.connectionId ? 'remote' : 'local', - async () => { - const result = repo.connectionId - ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) - : await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - localWorktreeGitOptions - ) - if (!result.success) { - console.error( - `[hooks] archive hook failed for ${canonicalWorktreePath}:`, - result.output - ) - } - } - ) - } - - const remoteConnectionId = repo.connectionId ?? undefined - if (remoteConnectionId) { - // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. - if (!args.force) { - const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) - if (!clean) { - const error = new Error('Worktree has uncommitted or untracked changes.') - ;(error as Error & { stdout?: string }).stdout = stdout - throw error - } - } - - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const removalGate = await withWorktreeRemoveStageSpan( - 'watcher_gate', - 'remote', - async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) - ) - let rawRemovalResult: RemoveWorktreeResult | undefined - let removalCompleted = false - try { - await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: remoteConnectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - rawRemovalResult = await withWorktreeRemoveStageSpan( - 'git_remove', - 'remote', - async () => - Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree( - canonicalWorktreePath, - args.force, - remoteRemoveOptions - ) - : provider!.removeWorktree(canonicalWorktreePath, args.force) - ) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - const removalResult = preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - await deleteRemoteWorktreeHistory( - getSshPtyProvider(remoteConnectionId), - args.worktreeId - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: `orca.yaml` shared directories are symlinked in too, and a - // directory-only ignore rule leaves those links untracked, so removal must - // tolerate and unlink them exactly like the per-user shared paths. - const linkedPaths = getWorktreeSharedLinkPaths(repo) - const ignoredLinkedPaths = args.force - ? [] - : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ...localWorktreeGitOptions, - ...(ignoredLinkedPaths.length > 0 - ? { ignoredUntrackedPaths: ignoredLinkedPaths } - : {}) - }) - : ignoredLinkedPaths.length > 0 - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ignoredUntrackedPaths: ignoredLinkedPaths - }) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. - } - - let removalResult: RemoveWorktreeResult | undefined - const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath) - ) - let removalCompleted = false - try { - // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. - // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. - await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - - // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. - if (linkedPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) - } - - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) - } - removalResult = preserveBranchHeadFallback( - await withWorktreeRemoveStageSpan('git_remove', 'local', async () => - removeWorktree( - repo.path, - canonicalWorktreePath, - args.force ?? false, - removeOptions - ) - ), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force: args.force ?? false, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - removalCompleted = true - } else if (isOrphanedWorktreeError(error)) { - // If git no longer tracks this worktree, clean up the directory and metadata - console.warn( - `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` - ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath( - canonicalWorktreePath, - localWorktreeGitOptions - ).catch(() => {}) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - removalCompleted = true - return {} - } else { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - } - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { - invalidateAuthorizedRootsCache() - }) - - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - }) - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) - try { - const result = await removal - options?.onWorktreeLifecycle?.({ - kind: 'removed', - worktreeId: args.worktreeId, - path: parseWorktreeId(args.worktreeId).worktreePath - }) - return result - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - // Why: drop a workspace locally with no remote work, so one pinned to a dead SSH target (where worktrees:remove throws) can still be cleared. - ipcMain.handle( - 'worktrees:forgetLocal', - async ( - _event, - args: Pick - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) - if (!args.hostId && repoOwner.kind === 'ambiguous') { - throw new Error( - `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` - ) - } - const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined - // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. - const inFlightKey = getWorktreeRemovalInFlightKey( - args.worktreeId, - repo - ? getRepoExecutionHostId(repo) - : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) - ) - const optionsKey = 'forget-local' - const inFlight = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlight) { - if (inFlight.optionsKey === optionsKey) { - return inFlight.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - const forget = (async (): Promise => { - const isFolderRootOf = (candidate: Repo): boolean => - isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) - const fallbackRepos = args.hostId - ? store - .getRepos() - .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) - : store.getRepos() - if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - - const ownerHostId = resolveWorktreeRemovalOwnerHostId( - store, - args.worktreeId, - repo, - args.hostId - ) - const ownerHost = parseExecutionHostId(ownerHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - // External host inventories must never sweep a same-id local workspace. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) - }) - - runtime.clearOptimisticReconcileToken(args.worktreeId) - // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - ownerHost?.id, - args.snapshotPruneBatchId - ) - // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. - invalidateAuthorizedRootsCache() - if (ownerHost?.id) { - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) - ) - } else { - for (const [key, target] of preservedBranchCleanupByScope) { - if (target.worktreeId === args.worktreeId) { - preservedBranchCleanupByScope.delete(key) - } - } - } - notifyWorktreesChanged(mainWindow, repoId) - return {} - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) - try { - return await forget - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - ipcMain.handle( - 'worktrees:forceDeletePreservedBranch', - async ( - _event, - args: { - worktreeId: string - branchName: string - expectedHead: string - hostId?: ExecutionHostId - } - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const cleanupTarget = getPreservedBranchCleanupTarget( - args.worktreeId, - args.branchName, - args.expectedHead, - args.hostId - ) - const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - if (isFolderRepo(repo)) { - throw new Error('Folder workspaces do not have local Git branches.') - } - - if (repo.connectionId) { - const provider = requireSshGitProvider(repo.connectionId) - // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. - await provider.forceDeletePreservedBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider, - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store - ) - } else { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - await (hasLocalWorktreeGitOptions - ? forceDeleteLocalBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) - ) - : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store, - localWorktreeGitOptions - ) - } - - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: cleanupTarget.hostId - }) - ) - return { deleted: true } - } - ) - - ipcMain.handle( - 'worktrees:updateMeta', - (_event, args: { worktreeId: string; updates: Partial }) => { - const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) - const updates = - validatedUpdates.displayName !== undefined - ? { - ...validatedUpdates, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - } - : validatedUpdates - const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) - // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). - if (args.updates.displayName !== undefined) { - // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. - runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) - } - return meta - } - ) - - ipcMain.handle('worktrees:listLineage', async () => { - await runtime.hydrateInferredWorktreeLineage() - return { - lineage: store.getAllWorktreeLineage(), - workspaceLineage: store.getAllWorkspaceLineage() - } - }) - - ipcMain.handle( - 'worktrees:listLineageForHost', - (_event, args: ListDesktopLineageForHostArgs): Promise => - listDesktopLineageForHost(store, runtime, args) - ) - - ipcMain.handle( - 'worktrees:updateLineage', - async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { - await runtime.updateManagedWorktreeMeta(args.worktreeId, { - lineage: - args.noParent === true - ? { noParent: true } - : args.parentWorktreeId - ? { parentWorktree: `id:${args.parentWorktreeId}` } - : undefined - }) - notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) - return store.getWorktreeLineage(args.worktreeId) ?? null - } - ) - - // Why: snapshot sidebar order for cold-start restore (ephemeral signals gone); one batch call avoids N updateMeta IPCs. - ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { - if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { - return - } - const updates = planWorktreeSortOrderUpdates( - args.orderedIds, - (worktreeId) => store.getWorktreeMeta(worktreeId), - Date.now() - ) - for (const update of updates) { - store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) - } - }) - - // Why: full failure output lives only in main memory (not worktree metadata), so the dialog pulls it on demand. - ipcMain.handle( - 'worktrees:getBranchRenameFailureOutput', - (_event, args: { worktreeId: string }) => { - if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { - return null - } - return readBranchRenameFailureOutputForDisplay(args.worktreeId) - } - ) - - ipcMain.handle( - 'hooks:check', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo) { - const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) - // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. - return { - status: args.hostId || repoIdExists ? 'error' : 'ok', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - if (isFolderRepo(repo)) { - return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - } - - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". - const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - return { - status: 'ok', - hasHooks: has, - hooks, - mayNeedUpdate - } - } - ) - - ipcMain.handle( - 'hooks:createIssueCommandRunner', - (_event, args: { repoId: string; worktreePath: string; command: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - return createIssueCommandRunnerScript( - repo, - args.worktreePath, - args.command, - getLocalProjectWorktreeGitOptions(store, repo), - resolveSetupRunnerShell(store.getSettings()) - ) - } - ) - - ipcMain.handle( - 'hooks:inspectSetupScriptImports', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return [] - } - - return inspectSetupScriptImportCandidates( - async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - - try { - return await readFile(filePath, 'utf-8') - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to inspect setup script import candidate:', error) - } - return null - } - }, - { - fileExists: async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - try { - const fileStat = await fsProvider.stat(filePath) - return fileStat.type !== 'directory' - } catch { - return false - } - } - - try { - const fileStat = await stat(filePath) - return !fileStat.isDirectory() - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to stat setup script import candidate:', error) - } - return false - } - } - } - ) - } - ) - - ipcMain.handle( - 'hooks:readIssueCommand', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return { - status: 'ok', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - status: 'error', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - - let status: 'ok' | 'error' = 'ok' - let localContent: string | null = null - let sharedContent: string | null = null - try { - const result = await fsProvider.readFile(issueCommandPath) - localContent = result.isBinary ? null : result.content.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - sharedContent = result.isBinary - ? null - : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - const effectiveContent = localContent ?? sharedContent - return { - status: localContent ? 'ok' : status, - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) - } - } - return readIssueCommand(repo.path) - } - ) - - ipcMain.handle( - 'hooks:writeIssueCommand', - async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - throw new Error( - 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' - ) - } - const trimmed = args.content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') - try { - const result = await fsProvider.readFile(gitignorePath) - if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { - const separator = result.content.endsWith('\n') ? '' : '\n' - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - await fsProvider.writeFile(gitignorePath, '.orca\n') - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return - } - writeIssueCommand(repo.path, args.content) - } - ) + registerWorktreeCatalogHandlers(context) + registerHostCatalogHandlers(context) + registerDetectedWorktreeHandlers(context) + registerWorktreePrefetchHandler(context) + registerWorktreeCreateHandlers(context) + registerReviewBaseHandlers(context) + registerWorktreeRemovalHandlers(context) + registerWorktreeForgetHandlers(context) + registerWorktreeMetadataHandlers(context) + registerWorktreeHookCheckHandler(context) + registerWorktreeHookRunnerHandler(context) + registerWorktreeHookInspectionHandler(context) + registerWorktreeHookFileHandlers(context) } diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts new file mode 100644 index 00000000000..9a1bc65fc02 --- /dev/null +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' +import type { Store } from '../../../persistence/loading-store/store' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model' + +export function createFolderWorkspace( + args: CreateWorktreeArgsWithSystemProvenance, + repo: Repo, + store: Store +): CreateWorktreeResult { + const now = Date.now() + const instanceId = randomUUID() + const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) + const meta = store.setWorktreeMeta(worktreeId, { + instanceId, + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + displayName: args.displayName || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + }) + return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } +} diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts new file mode 100644 index 00000000000..6ebe8f26fbd --- /dev/null +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -0,0 +1,120 @@ +import { ipcMain } from 'electron' +import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { gitExecFileAsync } from '../../../git/runner' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions +} from '../../../project-runtime-git-options' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + fetchPrHeadTrackingRef, + fetchGitHubPullRequestHeadRef +} from '../../../github/pr-head-tracking-ref' +import { resolveGitHubPrStartPoint } from '../../../github/pr-start-point' +import { resolveGitHubReviewHeadRemote } from '../../../github/review-head-remote' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:resolvePrBase', + async ( + _event, + args: { + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + } + ): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { + if (!repo.connectionId) { + return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + throw new Error( + 'SSH Git provider is not available. Reconnect to this target and try again.' + ) + } + return provider.exec(args, repo.path) + } + // Why: SSH review-head fetches require narrow write-capable RPCs. + const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => + fetchPrHeadTrackingRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + branch, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => + fetchGitHubPullRequestHeadRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + prNumber, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + + return resolveGitHubPrStartPoint({ + repoPath: repo.path, + prNumber: args.prNumber, + headRefName: args.headRefName, + baseRefName: args.baseRefName, + isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec, + fetchRemoteTrackingRef, + fetchPullRequestHeadRef, + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. + resolveRemote: () => + resolveGitHubReviewHeadRemote({ + repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec + }) + }) + } + ) + + ipcMain.handle( + 'worktrees:resolveMrBase', + async ( + _event, + args: { + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean + } + ): Promise< + | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } + | { error: string } + > => { + return runtime.resolveManagedMrBase({ + repoSelector: `id:${args.repoId}`, + mrIid: args.mrIid, + sourceBranch: args.sourceBranch, + targetBranch: args.targetBranch, + isCrossRepository: args.isCrossRepository + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts new file mode 100644 index 00000000000..775a0859790 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -0,0 +1,156 @@ +import { ipcMain, app } from 'electron' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + AdoptProvisionedRootArgs +} from '../../../../shared/worktree/create-types' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { workspaceSourceSchema } from '../../../../shared/telemetry-events' +import type { WorkspaceSource } from '../../../../shared/telemetry-events' +import { + resolveAutomationWorkspaceProvenance, + releaseAutomationWorkspaceProvenanceRequest, + finishAutomationWorkspaceProvenanceRequest +} from '../../../automations/workspace-provenance' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { + createRemoteWorktree, + createLocalWorktree, + notifyWorktreesChanged +} from '../../worktree-remote' +import { track } from '../../../telemetry/client' +import { classifyWorkspaceCreateError } from '../../workspace-create-error-classifier' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { adoptProvisionedRootSshCheckout } from '../../../provisioned-root-ssh-adoption' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { createFolderWorkspace } from './folder-workspace-creation' +import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, options } = context + + ipcMain.handle( + 'worktrees:create', + async (_event, rawArgs: CreateWorktreeArgs): Promise => { + const args = normalizeLinkedWorkItemFields(rawArgs) + // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } + + let result: CreateWorktreeResult + try { + // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. + result = isFolderRepo(repo) + ? createFolderWorkspace(createArgs, repo, store) + : repo.connectionId + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + + // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. + track('workspace_created', { + source, + from_existing_branch: + !isFolderRepo(repo) && + typeof args.baseBranch === 'string' && + args.baseBranch.length > 0, + ...getCohortAtEmit() + }) + + if (isFolderRepo(repo)) { + notifyWorktreesChanged(mainWindow, repo.id) + } + + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + + return result + }) + } + ) + + ipcMain.handle( + 'worktrees:adoptProvisionedRoot', + async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { + const args = normalizeLinkedWorkItemFields(rawArgs) + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo || isFolderRepo(repo)) { + throw new Error('Provisioned-root repository ownership is missing or ambiguous.') + } + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + let result: CreateWorktreeResult + try { + result = await adoptProvisionedRootSshCheckout({ + userDataPath: app.getPath('userData'), + request: { ...args, automationProvenance }, + repo, + store, + isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) + }) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_created', { + source, + from_existing_branch: false, + ...getCohortAtEmit() + }) + notifyWorktreesChanged(mainWindow, repo.id) + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return result + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts new file mode 100644 index 00000000000..19b0516f274 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -0,0 +1,22 @@ +import { ipcMain } from 'electron' +import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:prefetchCreateBase', + async (_event, args: { repoId: string; baseBranch?: string }): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return + } + try { + await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) + } catch { + // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. + } + } + ) +} diff --git a/src/main/ipc/worktrees/folder-workspace-model.ts b/src/main/ipc/worktrees/folder-workspace-model.ts new file mode 100644 index 00000000000..8f42175fce4 --- /dev/null +++ b/src/main/ipc/worktrees/folder-workspace-model.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../shared/repo-types' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { Worktree } from '../../../shared/worktree/types' + +export function getFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +export function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +export function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +export function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + linkedBitbucketPR: meta.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: meta.linkedGiteaPR ?? null, + linkedWorkItem: meta.linkedWorkItem ?? null, + linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), + ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments, + mobileDiffReview: meta.mobileDiffReview + } +} diff --git a/src/main/ipc/worktrees/ipc-context-schemas.ts b/src/main/ipc/worktrees/ipc-context-schemas.ts new file mode 100644 index 00000000000..f29f09b39d4 --- /dev/null +++ b/src/main/ipc/worktrees/ipc-context-schemas.ts @@ -0,0 +1,58 @@ +import type { CreateWorktreeArgs } from '../../../shared/worktree/create-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance +} from '../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ListDetectedWorktreesArgs } from '../../../shared/detected-worktree-provider-contract' +import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' + +export type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance +} + +export type RemoveWorktreeArgs = { + worktreeId: string + hostId?: ExecutionHostId + force?: boolean + /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ + allowUnverifiedPtyStop?: boolean + skipArchive?: boolean + snapshotPruneBatchId?: string +} + +export type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs + +export const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() +export const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() + +export function normalizeLinkedWorkItemFields< + T extends { + linkedWorkItem?: unknown + linkedTaskSourceContext?: unknown + } +>(input: T): T { + const linkedWorkItem = + input.linkedWorkItem === undefined + ? undefined + : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) + const linkedTaskSourceContext = + input.linkedTaskSourceContext === undefined + ? undefined + : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) + if ( + linkedWorkItem && + linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) + ) { + throw new Error('Linked work item and source context identities must match') + } + return { + ...input, + ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) + } +} diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts new file mode 100644 index 00000000000..c3692d5c926 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -0,0 +1,163 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' +import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' +import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' + +export async function listDetectedWorktreesForCapturedRepo( + store: Store, + repo: Repo, + isCurrent: () => boolean, + capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const abortedResult = () => + providerAbort?.signal.aborted + ? ({ providerAbortStatus: providerAbort.status() } as const) + : undefined + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees: GitWorktreeInfo[] + let freshScan = true + if (isFolderRepo(repo)) { + if (!isCurrent()) { + return null + } + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + } + } + if (repo.connectionId) { + if (!capturedProvider) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + gitWorktrees = await capturedProvider.listWorktrees(repo.path, { + signal: providerAbort?.signal + }) + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) + if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + } + } catch (err) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + if (repo.connectionId) { + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } + } +} + +export function hasValidDirectSshAuthority( + args: DirectSshDetectedWorktreeRequest +): args is DirectSshDetectedWorktreeRequest { + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +export function hasValidLineageSshAuthority( + args: ListDesktopLineageForHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts new file mode 100644 index 00000000000..55b3d75e3ed --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -0,0 +1,133 @@ +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { listRepoWorktrees } from '../../../repo-worktrees' +import { registerWorktreeRootsForRepo } from '../../registered-worktree-roots-cache' + +// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. +export const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +export type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +export type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + +export type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +export const detectedWorktreeScanCache = new Map() +export const detectedWorktreeScanInFlight = new Map() + +export function invalidateDetectedWorktreeScanCache(repoId: string): void { + const keyPrefix = `${repoId}\0` + for (const key of new Set([ + ...detectedWorktreeScanCache.keys(), + ...detectedWorktreeScanInFlight.keys() + ])) { + if (!key.startsWith(keyPrefix)) { + continue + } + detectedWorktreeScanCache.delete(key) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } + } +} + +export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } +} + +export async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), + fresh: true + } + } + + const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) + const cached = detectedWorktreeScanCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(cacheKey) + if (inFlight) { + return { gitWorktrees: await inFlight.promise, fresh: false } + } + + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } + detectedWorktreeScanInFlight.set(cacheKey, scan) + try { + const gitWorktrees = await scan.promise + // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. + if (!scan.invalidated) { + detectedWorktreeScanCache.set(cacheKey, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: !scan.invalidated } + } finally { + if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { + detectedWorktreeScanInFlight.delete(cacheKey) + } + } +} + +export function getDetectedWorktreeScanCacheKey( + repoId: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): string { + return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` +} + +export function rememberLocalWorktreeRoots( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if (repo.connectionId) { + return + } + // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. + registerWorktreeRootsForRepo(store, repo.id, [ + repo.path, + ...gitWorktrees.map((worktree) => worktree.path) + ]) +} diff --git a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts new file mode 100644 index 00000000000..abaa594bf6e --- /dev/null +++ b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts @@ -0,0 +1,118 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree, DetectedWorktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { + toDetectedWorktree, + applyMetadataFallbackVisibility +} from '../../../../shared/worktree/ownership' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getProjectHostSetupMetaUpdates } from './worktree-discovery-metadata' +import { + getFolderWorkspaceInstanceIdentity, + getFolderWorkspaceRootId, + isFolderWorkspaceIdForRepo, + mergeFolderWorkspace +} from '../folder-workspace-model' + +export function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = getFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const ids = Object.keys(allMeta).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } + + return ids + .map((worktreeId) => { + const existing = allMeta[worktreeId] + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(ownershipUpdates).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...ownershipUpdates, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + return mergeFolderWorkspace(repo, worktreeId, meta) + }) + .sort((a, b) => { + if (a.id === rootId) { + return -1 + } + if (b.id === rootId) { + return 1 + } + return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) + }) +} + +export function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { + const settings = store.getSettings() + const worktrees = listFolderWorkspaces(store, repo) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + return worktrees.map((worktree) => + toDetectedWorktree({ + repo, + worktree, + meta: store.getWorktreeMeta(worktree.id), + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + ) +} + +export function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + return buildFolderDetectedWorktrees(store, repo) + .filter((worktree) => worktree.visible) + .map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) + const repairedMeta = + meta && Object.keys(ownershipUpdates).length === 0 + ? meta + : store.setWorktreeMeta(worktree.id, ownershipUpdates) + return mergeFolderWorkspace(repo, worktree.id, repairedMeta) + }) +} + +export function buildDisconnectedDetectedWorktrees( + store: Store, + repo: Repo, + worktrees: Worktree[] +): DetectedWorktree[] { + const settings = store.getSettings() + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = worktrees.map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + return applyMetadataFallbackVisibility(detected) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} diff --git a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts new file mode 100644 index 00000000000..3026a1b0652 --- /dev/null +++ b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts @@ -0,0 +1,133 @@ +import type { Store } from '../../../persistence/loading-store/store' +import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES } from '../../../../shared/detected-worktree-provider-contract' +import type { + ListDetectedWorktreesArgs, + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { + findExactRepoOwner, + isCapturedRepoCurrent, + resolveRepoOwnershipEvidence +} from './worktree-host-ownership' + +export async function listHostQualifiedDetectedWorktrees( + store: Store, + args: ListDetectedWorktreesArgs, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const parsedHost = parseExecutionHostId(args.executionHostId) + const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status + }) + if ( + typeof args.providerRequestId !== 'string' || + args.providerRequestId.length === 0 || + Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || + !parsedHost || + parsedHost.kind === 'runtime' + ) { + return rejected('rejected') + } + let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null + if (parsedHost.kind === 'ssh') { + const directArgs = args as DirectSshDetectedWorktreeRequest + if ( + !hasValidDirectSshAuthority(directArgs) || + directArgs.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + capturedAuthority = { ...directArgs.expectedAuthority } + if (!isCurrentSshProviderAuthority(capturedAuthority)) { + return rejected('stale') + } + } + + const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected('rejected') + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo) { + return rejected('ambiguous-owner') + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return rejected('rejected') + } + const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined + const isCurrent = (): boolean => { + if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { + return false + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return false + } + if (parsedHost.kind !== 'ssh') { + return true + } + return ( + capturedAuthority !== null && + getSshGitProvider(parsedHost.targetId) === provider && + isCurrentSshProviderAuthority(capturedAuthority) + ) + } + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + isCurrent, + provider, + providerAbort + ) + if (!result) { + return rejected('stale') + } + if ('providerAbortStatus' in result) { + return { + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: result.providerAbortStatus + } + } + const status = result.authoritative ? 'complete' : 'non-authoritative' + if (parsedHost.kind === 'local') { + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + if (!capturedAuthority) { + return rejected('rejected') + } + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { + kind: 'direct-ssh', + executionHostId: args.executionHostId as `ssh:${string}`, + ...capturedAuthority + }, + result + } +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts new file mode 100644 index 00000000000..66e8e85a4a5 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -0,0 +1,134 @@ +import { ipcMain } from 'electron' +import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' +import type { + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest, + ProviderRequestId +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + registerSshProviderRequestAbort, + getSshProviderAuthority, + isCurrentSshProviderAuthority +} from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeRequestArgs } from '../ipc-context-schemas' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { listHostQualifiedDetectedWorktrees } from './host-qualified-worktree-listing' +import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 + +export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): void { + const { store, detectedWorktreeCancellations } = context + + ipcMain.handle( + 'worktrees:listDetected', + async ( + event, + args: DetectedWorktreeRequestArgs + ): Promise => { + if ('executionHostId' in args) { + const parsedHost = parseExecutionHostId(args.executionHostId) + const directSshRequest = parsedHost?.kind === 'ssh' + const controller = directSshRequest + ? detectedWorktreeCancellations.begin(event, args.providerRequestId) + : null + const directArgs = args as DirectSshDetectedWorktreeRequest + const removeAuthorityAbort = + controller && + parsedHost?.kind === 'ssh' && + hasValidDirectSshAuthority(directArgs) && + directArgs.expectedAuthority.targetId === parsedHost.targetId + ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) + : undefined + let timedOut = false + let removeAbortListener: (() => void) | undefined + const abortedResult = controller + ? new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: timedOut ? 'timed-out' : 'canceled' + }) + } + controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) + }) + : undefined + const timeout = controller + ? setTimeout(() => { + timedOut = true + controller.abort() + }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) + : undefined + try { + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) + return abortedResult + ? await Promise.race([providerResult, abortedResult]) + : await providerResult + } finally { + if (timeout) { + clearTimeout(timeout) + } + removeAbortListener?.() + removeAuthorityAbort?.() + detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) + } + } + const repo = findExactRepoOwner(store, args.repoId) + if (!repo) { + return { + repoId: args.repoId, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined + const authority = repo.connectionId + ? { ...getSshProviderAuthority(repo.connectionId) } + : undefined + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!repo.connectionId || + (getSshGitProvider(repo.connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) + return result && !('providerAbortStatus' in result) + ? result + : { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + ) + + ipcMain.handle( + 'worktrees:cancelListDetected', + (event, args: { providerRequestId: ProviderRequestId }): void => { + detectedWorktreeCancellations.cancel(event, args.providerRequestId) + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts new file mode 100644 index 00000000000..222ae1bf999 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts @@ -0,0 +1,6 @@ +import { registerWorktreeChangeInvalidator } from '../../worktree-change-invalidators' +import { invalidateDetectedWorktreeScanCache } from './detected-worktree-scan-cache' + +export function registerDetectedWorktreeScanInvalidation(): void { + registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) +} diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts new file mode 100644 index 00000000000..50ce6082a42 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -0,0 +1,140 @@ +import { ipcMain } from 'electron' +import type { + ListKnownWorktreesForExecutionHostArgs, + HostQualifiedKnownWorktreeResult, + ForgetRemovedWorktreesForExecutionHostArgs, + ForgetRemovedWorktreesForExecutionHostResult +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { DetectedWorktree } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' +import { findExactRepoOwner, hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { + createSshWorktreeMetaIndexForRepo, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + + ipcMain.handle( + 'worktrees:forgetRemovedForExecutionHost', + ( + _event, + args: ForgetRemovedWorktreesForExecutionHostArgs + ): ForgetRemovedWorktreesForExecutionHostResult => { + const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { + forgottenWorktreeIds: [] + } + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + return nothingForgotten + } + const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return nothingForgotten + } + // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips + // those keys for the same reason, and no remote scan can retire one. + if (isFolderRepo(repo)) { + return nothingForgotten + } + const allMeta = store.getAllWorktreeMeta() + const forgottenWorktreeIds: string[] = [] + for (const worktreeId of worktreeIds) { + const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined + if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { + continue + } + // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. + if (meta.hostId && meta.hostId !== requestedExecutionHostId) { + continue + } + store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + forgottenWorktreeIds.push(worktreeId) + } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } + return { forgottenWorktreeIds } + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts new file mode 100644 index 00000000000..64c28c08495 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -0,0 +1,181 @@ +import { ipcMain } from 'electron' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees, + stampAndMergeVisibleDetectedWorktree +} from './ssh-worktree-fallback' +import { listVisibleFolderWorkspaces } from './folder-workspace-catalog' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { + loggedUnavailableSshGitProviders, + loggedWorktreeListFailures, + warnOnce +} from './worktree-listing-diagnostics' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +const WORKTREE_LIST_ALL_CONCURRENCY = 8 + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle('worktrees:listAll', async () => { + const repos = store.getRepos() + const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. + const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. + return [] + } + }) + + return results.flat() + }) + + ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return EMPTY_RETIRED_NAME_REGISTRY + } + return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) + }) + + ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return [] + } + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. + return [] + } + }) +} diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts new file mode 100644 index 00000000000..26fb14f2341 --- /dev/null +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -0,0 +1,186 @@ +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' +import { + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../../../../shared/worktree/id' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + buildKnownOrcaWorkspaceLayouts, + isLegacyRepoForExternalWorktreeVisibility, + toDetectedWorktree +} from '../../../../shared/worktree/ownership' +import { dedupeWorktreesByPath } from '../../worktree-path-comparison' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { loggedMalformedWorktreeMetaKeys, warnOnce } from './worktree-listing-diagnostics' +import { + getProjectHostSetupMetaUpdates, + resolveWorktreeMetaWithDiscoveryBackfill +} from './worktree-discovery-metadata' + +export type SshWorktreeMetaCandidate = { + id: string + path: string + meta: WorktreeMeta +} + +export type SshWorktreeMetaIndex = Map + +export function createSshWorktreeMetaIndex( + entries: [string, WorktreeMeta][] +): SshWorktreeMetaIndex { + const index: SshWorktreeMetaIndex = new Map() + for (const [worktreeId, meta] of entries) { + let parsed: { repoId: string; worktreePath: string } + try { + parsed = parseWorktreeId(worktreeId) + } catch (err) { + warnOnce( + loggedMalformedWorktreeMetaKeys, + worktreeId, + `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, + err + ) + continue + } + + const candidates = index.get(parsed.repoId) ?? [] + candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) + index.set(parsed.repoId, candidates) + } + return index +} + +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +export function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + +export function synthesizeSshGitWorktree( + repo: Repo, + path: string, + meta: WorktreeMeta +): GitWorktreeInfo { + return { + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: areWorktreePathsEqual(path, repo.path), + ...(meta.sparseDirectories !== undefined || + meta.sparseBaseRef !== undefined || + meta.sparsePresetId !== undefined + ? { isSparse: true } + : {}) + } +} + +export function listDisconnectedSshWorktrees( + store: Store, + repo: Repo, + metaIndex: SshWorktreeMetaIndex +): Worktree[] { + const byWorktreeId = new Map() + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + for (const candidate of metaIndex.get(repo.id) ?? []) { + if ( + (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || + (!candidate.meta.hostId && repoOwners.length > 1) + ) { + continue + } + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) + const meta = + Object.keys(ownershipUpdates).length > 0 + ? { ...candidate.meta, ...ownershipUpdates } + : candidate.meta + if (Object.keys(ownershipUpdates).length > 0) { + store.setWorktreeMeta(candidate.id, ownershipUpdates) + } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. + const worktree = mergeWorktree( + repo.id, + synthesizeSshGitWorktree(repo, candidate.path, meta), + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined + ) + byWorktreeId.delete(worktree.id) + byWorktreeId.set(worktree.id, worktree) + } + return [...byWorktreeId.values()] +} + +export function buildDetectedGitWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): DetectedWorktree[] { + const settings = store.getSettings() + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) + const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) + // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. + const liveWorktrees = dedupeWorktreesByPath( + gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + ) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = liveWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + let meta = store.getWorktreeMeta(worktreeId) + const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + if (!detected.visible) { + return detected + } + + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) + return toDetectedWorktree({ + repo, + worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} + +export function stampAndMergeVisibleDetectedWorktree( + store: Store, + repo: Repo, + detected: DetectedWorktree +) { + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) + return mergeWorktree(repo.id, detected, meta, repo.displayName) +} diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts new file mode 100644 index 00000000000..c96e8dc2978 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -0,0 +1,51 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import { randomUUID } from 'node:crypto' + +export function getProjectHostSetupMetaUpdates( + store: Store, + repo: Repo, + existing?: WorktreeMeta +): Partial> { + const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + const sameSetup = + existing?.projectHostSetupId === undefined || + existing.projectHostSetupId === ownership.projectHostSetupId + return { + // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. + ...(sameSetup && existing?.projectId !== ownership.projectId + ? { projectId: ownership.projectId } + : {}), + ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), + ...(existing?.projectHostSetupId === undefined + ? { projectHostSetupId: ownership.projectHostSetupId } + : {}) + } +} + +// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. +export function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + if (existing) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...ownershipUpdates + } + if (Object.keys(updates).length > 0) { + // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) + } + return existing + } + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...ownershipUpdates + }) +} diff --git a/src/main/ipc/worktrees/listing/worktree-host-ownership.ts b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts new file mode 100644 index 00000000000..1f9eee701d8 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts @@ -0,0 +1,82 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export function hasConflictingStoredWorktreeOwner( + store: Store, + repo: Repo, + worktreeIds: readonly string[] +): boolean { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + return worktreeIds.some((worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) + }) +} + +export type RepoOwnershipEvidence = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'malformed' } + | { status: 'contradictory' } + +export function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return { status: 'malformed' } + } + const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined + const connectionId = hasConnection ? repo.connectionId?.trim() : null + if (hasConnection && !connectionId) { + return { status: 'malformed' } + } + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { + return { status: 'contradictory' } + } + return { + status: 'owned', + hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID + } +} + +export function findExactRepoOwner( + store: Store, + repoId: string, + executionHostId?: ExecutionHostId +): Repo | undefined { + const candidates = store.getRepos().filter((repo) => repo.id === repoId) + const evidence = candidates.map(resolveRepoOwnershipEvidence) + if (evidence.some((owner) => owner.status !== 'owned')) { + return undefined + } + const matches = candidates.filter((_, index) => { + const owner = evidence[index] + return ( + owner?.status === 'owned' && + (executionHostId === undefined || owner.hostId === executionHostId) + ) + }) + return matches.length === 1 ? matches[0] : undefined +} + +export function isCapturedRepoCurrent( + store: Store, + repo: Repo, + executionHostId?: ExecutionHostId +): boolean { + const current = findExactRepoOwner(store, repo.id, executionHostId) + return ( + current !== undefined && + current.path === repo.path && + (current.connectionId ?? null) === (repo.connectionId ?? null) && + (current.executionHostId ?? null) === (repo.executionHostId ?? null) + ) +} diff --git a/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts new file mode 100644 index 00000000000..128bb46dd28 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts @@ -0,0 +1,16 @@ +import { shouldEmitBoundedWarning } from '../../bounded-warning-dedupe' + +export const loggedUnavailableSshGitProviders = new Set() +export const loggedWorktreeListFailures = new Set() +export const loggedMalformedWorktreeMetaKeys = new Set() + +export function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (!shouldEmitBoundedWarning(keySet, key)) { + return + } + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} diff --git a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts new file mode 100644 index 00000000000..a2ec052fe68 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts @@ -0,0 +1,110 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence/loading-store/store' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { hasValidLineageSshAuthority } from '../listing/detected-provider-listing' +import { filterLineageForHost } from './workspace-lineage-filtering' + +export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 + +export async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { + let timeout: ReturnType | undefined + const hydration = Promise.resolve() + .then(() => runtime.hydrateInferredWorktreeLineage()) + .then( + () => true, + () => false + ) + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) + }) + try { + return await Promise.race([hydration, deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +export async function listDesktopLineageForHost( + store: Store, + runtime: OrcaRuntimeService, + args: ListDesktopLineageForHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostLineageSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + let provider: SshGitProvider | undefined + let authority: + | Extract['expectedAuthority'] + | null = null + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + } else { + if ( + !hasValidLineageSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + } + if (!(await hydrateLineageWithinDeadline(runtime))) { + return rejected('unavailable') + } + if ( + parsedHost.kind === 'ssh' && + (!authority || + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority)) + ) { + return rejected('stale') + } + const lineage = filterLineageForHost(store, parsedHost.id) + if (!lineage) { + return rejected('ambiguous-owner') + } + if (parsedHost.kind === 'local') { + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + ...lineage + } + } + if (!authority) { + return rejected('authority-unknown') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + ...lineage + } +} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts new file mode 100644 index 00000000000..a8a58bf81d1 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts @@ -0,0 +1,123 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { parseWorktreeId } from '../../worktree-logic' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { resolveRepoOwnershipEvidence } from '../listing/worktree-host-ownership' + +export type LineageOwner = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'ambiguous' | 'contradictory' | 'runtime' } + +export type LineageFolder = FolderWorkspace +export type LineageGroup = ProjectGroup + +export type LineageResolutionContext = { + store: Store + repos: Repo[] + groups: LineageGroup[] + reposById: Map + foldersById: Map + groupsById: Map + groupSubtreeIdsByRoot: Map> + worktreeOwners: Map + folderOwners: Map + workspaceOwners: Map +} + +export function indexLineageEntriesById( + entries: readonly T[] +): Map { + const index = new Map() + for (const entry of entries) { + const matching = index.get(entry.id) ?? [] + matching.push(entry) + index.set(entry.id, matching) + } + return index +} + +export function createLineageResolutionContext(store: Store): LineageResolutionContext { + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + const groups = store.getProjectGroups() + return { + store, + repos, + groups, + reposById: indexLineageEntriesById(repos), + foldersById: indexLineageEntriesById(folders), + groupsById: indexLineageEntriesById(groups), + groupSubtreeIdsByRoot: new Map(), + worktreeOwners: new Map(), + folderOwners: new Map(), + workspaceOwners: new Map() + } +} + +export function resolveRepoLineageOwner(repo: Repo): LineageOwner { + const owner = resolveRepoOwnershipEvidence(repo) + if (owner.status === 'malformed') { + return { status: 'ambiguous' } + } + if (owner.status === 'contradictory') { + return { status: 'contradictory' } + } + return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner +} + +export function resolveWorktreeLineageOwner( + context: LineageResolutionContext, + worktreeId: string +): LineageOwner { + const cached = context.worktreeOwners.get(worktreeId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.worktreeOwners.set(worktreeId, owner) + return owner + } + let repoId: string + try { + repoId = parseWorktreeId(worktreeId).repoId + } catch { + return remember({ status: 'ambiguous' }) + } + const repos = context.reposById.get(repoId) ?? [] + const meta = context.store.getWorktreeMeta(worktreeId) + const runtimeOwnerEnvironmentId = ( + meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined + )?.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwnerEnvironmentId) { + return remember({ status: 'runtime' }) + } + if (meta?.hostId) { + const explicitHost = parseExecutionHostId(meta.hostId) + if (!explicitHost) { + return remember({ status: 'ambiguous' }) + } + if (explicitHost.kind === 'runtime') { + return remember({ status: 'runtime' }) + } + const matchingRepos = repos.filter((repo) => { + const owner = resolveRepoLineageOwner(repo) + return owner.status === 'owned' && owner.hostId === explicitHost.id + }) + if (matchingRepos.length === 1) { + return remember({ status: 'owned', hostId: explicitHost.id }) + } + return remember( + matchingRepos.length > 1 + ? { status: 'ambiguous' } + : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } + ) + } + if (repos.length !== 1) { + return remember({ status: 'ambiguous' }) + } + return remember(resolveRepoLineageOwner(repos[0])) +} diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts new file mode 100644 index 00000000000..97d175a70df --- /dev/null +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -0,0 +1,95 @@ +import { ipcMain } from 'electron' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { parseWorktreeId } from '../../worktree-logic' +import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' +import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import { listDesktopLineageForHost } from './host-lineage-listing' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime } = context + + ipcMain.handle( + 'worktrees:updateMeta', + (_event, args: { worktreeId: string; updates: Partial }) => { + const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) + const updates = + validatedUpdates.displayName !== undefined + ? { + ...validatedUpdates, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + } + : validatedUpdates + const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) + // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). + if (args.updates.displayName !== undefined) { + // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } + return meta + } + ) + + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return { + lineage: store.getAllWorktreeLineage(), + workspaceLineage: store.getAllWorkspaceLineage() + } + }) + + ipcMain.handle( + 'worktrees:listLineageForHost', + (_event, args: ListDesktopLineageForHostArgs): Promise => + listDesktopLineageForHost(store, runtime, args) + ) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + + ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { + if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { + return + } + const updates = planWorktreeSortOrderUpdates( + args.orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + }) + + ipcMain.handle( + 'worktrees:getBranchRenameFailureOutput', + (_event, args: { worktreeId: string }) => { + if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { + return null + } + return readBranchRenameFailureOutputForDisplay(args.worktreeId) + } + ) +} diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts new file mode 100644 index 00000000000..3106812418c --- /dev/null +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts @@ -0,0 +1,184 @@ +import type { Repo } from '../../../../shared/repo-types' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Store } from '../../../persistence/loading-store/store' +import type { WorktreeLineage, WorkspaceLineage } from '../../../../shared/worktree/lineage-types' +import { + createLineageResolutionContext, + resolveRepoLineageOwner, + resolveWorktreeLineageOwner +} from './lineage-owner-resolution' +import type { + LineageFolder, + LineageOwner, + LineageResolutionContext +} from './lineage-owner-resolution' + +export function getFolderLineageCandidateRepos( + context: LineageResolutionContext, + folder: LineageFolder +): Repo[] { + let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) + if (!groupIds) { + groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) + context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) + } + const grouped = context.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = context.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + const group = context.groupsById.get(folder.projectGroupId)?.[0] + const connectionId = folder.connectionId ?? group?.connectionId ?? null + return connectionId + ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + : grouped.length > 0 + ? [ + ...grouped, + ...pathRepos.filter((repo) => + new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( + repo.connectionId ?? null + ) + ) + ] + : pathRepos +} + +export function resolveFolderLineageOwner( + context: LineageResolutionContext, + folderWorkspaceId: string +): LineageOwner { + const cached = context.folderOwners.get(folderWorkspaceId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.folderOwners.set(folderWorkspaceId, owner) + return owner + } + const folders = context.foldersById.get(folderWorkspaceId) ?? [] + if (folders.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const folder = folders[0] + const groups = context.groupsById.get(folder.projectGroupId) ?? [] + if (groups.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const group = groups[0] + const hosts = new Set() + if (folder.connectionId) { + hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) + } + if (group.connectionId) { + hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) + } + if (group.executionHostId) { + const parsed = parseExecutionHostId(group.executionHostId) + if (!parsed) { + return remember({ status: 'ambiguous' }) + } + hosts.add(parsed.id) + } + for (const repo of getFolderLineageCandidateRepos(context, folder)) { + const owner = resolveRepoLineageOwner(repo) + if (owner.status !== 'owned') { + return remember(owner) + } + hosts.add(owner.hostId) + } + if (hosts.size > 1) { + return remember({ status: 'contradictory' }) + } + const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID + return remember( + parseExecutionHostId(hostId)?.kind === 'runtime' + ? { status: 'runtime' } + : { status: 'owned', hostId } + ) +} + +export function resolveWorkspaceLineageOwner( + context: LineageResolutionContext, + workspaceKey: string +): LineageOwner { + const cached = context.workspaceOwners.get(workspaceKey) + if (cached) { + return cached + } + const workspace = parseWorkspaceKey(workspaceKey) + const owner = !workspace + ? { status: 'ambiguous' as const } + : workspace.type === 'worktree' + ? resolveWorktreeLineageOwner(context, workspace.worktreeId) + : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) + context.workspaceOwners.set(workspaceKey, owner) + return owner +} + +export function filterLineageForHost( + store: Store, + executionHostId: ExecutionHostId +): { + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} | null { + const context = createLineageResolutionContext(store) + const worktreeLineageById: Record = {} + const workspaceLineageByChildKey: Record = {} + for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + const child = resolveWorktreeLineageOwner(context, worktreeId) + const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + worktreeLineageById[worktreeId] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { + const child = resolveWorkspaceLineageOwner(context, childKey) + const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + workspaceLineageByChildKey[childKey] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + return { worktreeLineageById, workspaceLineageByChildKey } +} diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts new file mode 100644 index 00000000000..6f0d91ffef5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -0,0 +1,181 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner' +import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' +import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../../../local-worktree-removal-recovery' +import { runHook } from '../../../hooks' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { formatWorktreeRemovalError } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getArchiveHooksForRemoval, runRemoteArchiveHook } from './worktree-archive-hook' +import { isAlreadyRemovedWorktreePath } from './worktree-removal-filesystem' +import { rememberPreservedBranchCleanupTarget } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' +import { removeFolderWorkspace } from './remove-folder-workspace' +import { removeUnregisteredWorktree } from './remove-unregistered-worktree' +import { removeRegisteredRemoteWorktree } from './remove-registered-remote-worktree' +import { removeRegisteredLocalWorktree } from './remove-registered-local-worktree' + +export async function executeWorktreeRemoval( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (isFolderRepo(repo)) { + return removeFolderWorkspace(context, args, repo, repoId, removalHostId) + } + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const removedMeta = resolveWorktreeRemovalMetadata(store, repoId, args.worktreeId, removalHostId) + const removedPushTarget = removedMeta?.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ) + if (!registeredWorktree) { + return removeUnregisteredWorktree( + context, + args, + repo, + repoId, + worktreePath, + removalHostId, + registeredWorktrees, + removedMeta, + removedPushTarget, + localWorktreeGitOptions, + provider + ) + } + const canonicalWorktreePath = registeredWorktree.path + + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + if ( + !repo.connectionId && + args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + + const hooks = await getArchiveHooksForRemoval(repo) + + const archiveScript = hooks?.scripts.archive + + if (archiveScript && !args.skipArchive) { + // Why the branch on connectionId: this block is shared by both flows, so a hardcoded + // 'remote' would file every local archive hook under the SSH breakdown. + await withWorktreeRemoveStageSpan( + 'archive_hook', + repo.connectionId ? 'remote' : 'local', + async () => { + const result = repo.connectionId + ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) + : await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + localWorktreeGitOptions + ) + if (!result.success) { + console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) + } + } + ) + } + + const remoteConnectionId = repo.connectionId ?? undefined + if (remoteConnectionId) { + return removeRegisteredRemoteWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + registeredWorktree, + removedPushTarget, + provider!, + deleteBranch + ) + } + return removeRegisteredLocalWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch + ) +} diff --git a/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..948e997ccd0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts @@ -0,0 +1,80 @@ +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId + branchName: string + head: string + pushTarget?: GitPushTarget +} + +export const preservedBranchCleanupByScope = new Map() + +export function rememberPreservedBranchCleanupTarget( + worktreeId: string, + hostId: ExecutionHostId, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined +): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + }) + return + } + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) +} + +export function preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined +): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } +} + +export function getPreservedBranchCleanupTarget( + worktreeId: string, + branchName: string, + expectedHead: string, + hostId?: ExecutionHostId +): PreservedBranchCleanupTarget { + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) + if (!target || target.branchName !== branchName || target.head !== expectedHead) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + return target +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts new file mode 100644 index 00000000000..393ccc1a77b --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -0,0 +1,221 @@ +import { ipcMain } from 'electron' +import type { + RemoveWorktreeResult, + ForceDeleteWorktreeBranchResult +} from '../../../../shared/worktree/create-types' +import { parseWorktreeId } from '../../worktree-logic' +import { resolveWorktreeRemovalRepoOwner } from '../../../worktree-removal-repo-owner' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + notifyWorktreesChanged, + cleanupUnusedWorktreePushTargetRemoteSsh, + cleanupUnusedWorktreePushTargetRemote +} from '../../worktree-remote' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { forceDeleteLocalBranch } from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import { getWorktreeRemovalInFlightKey } from './worktree-removal-coordinator' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { + removeWorktreeMetadataAndTransientState, + resolveWorktreeRemovalOwnerHostId +} from './worktree-removal-ownership' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' +import { + getPreservedBranchCleanupTarget, + preservedBranchCleanupByScope +} from './preserved-branch-cleanup' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:forgetLocal', + async ( + _event, + args: Pick + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) + if (!args.hostId && repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined + // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. + const inFlightKey = getWorktreeRemovalInFlightKey( + args.worktreeId, + repo + ? getRepoExecutionHostId(repo) + : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) + ) + const optionsKey = 'forget-local' + const inFlight = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlight) { + if (inFlight.optionsKey === optionsKey) { + return inFlight.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + const forget = (async (): Promise => { + const isFolderRootOf = (candidate: Repo): boolean => + isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) + const fallbackRepos = args.hostId + ? store + .getRepos() + .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) + : store.getRepos() + if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + + const ownerHostId = resolveWorktreeRemovalOwnerHostId( + store, + args.worktreeId, + repo, + args.hostId + ) + const ownerHost = parseExecutionHostId(ownerHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + // External host inventories must never sweep a same-id local workspace. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) + + runtime.clearOptimisticReconcileToken(args.worktreeId) + // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) + // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. + invalidateAuthorizedRootsCache() + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } + notifyWorktreesChanged(mainWindow, repoId) + return {} + })() + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) + try { + return await forget + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) + + ipcMain.handle( + 'worktrees:forceDeletePreservedBranch', + async ( + _event, + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const cleanupTarget = getPreservedBranchCleanupTarget( + args.worktreeId, + args.branchName, + args.expectedHead, + args.hostId + ) + const repo = resolveRepoForExecutionHost(store, repoId, cleanupTarget.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. + await provider.forceDeletePreservedBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store + ) + } else { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + await (hasLocalWorktreeGitOptions + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store, + localWorktreeGitOptions + ) + } + + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) + return { deleted: true } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts new file mode 100644 index 00000000000..5aff34966c8 --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -0,0 +1,58 @@ +import { ipcMain } from 'electron' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { parseWorktreeId } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { executeWorktreeRemoval } from './execute-worktree-removal' +import { + getWorktreeRemovalInFlightKey, + getWorktreeRemovalOptionsKey +} from './worktree-removal-coordinator' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' + +export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { + const { store, options, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:remove', + async (_event, args: RemoveWorktreeArgs): Promise => { + const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) + const repo = resolveRepoForExecutionHost(store, repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + // The resolved repo supplies host ownership when legacy callers omit args.hostId. + const removalHostId = getRepoExecutionHostId(repo) + const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) + const optionsKey = getWorktreeRemovalOptionsKey(args) + const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlightRemoval) { + if (inFlightRemoval.optionsKey === optionsKey) { + return inFlightRemoval.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. + const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, () => + executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + ) + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) + try { + const result = await removal + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: parseWorktreeId(args.worktreeId).worktreePath + }) + return result + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts new file mode 100644 index 00000000000..c03b1449136 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts @@ -0,0 +1,70 @@ +import type { Repo } from '../../../../shared/repo-types' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from '../../pty' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' + +export async function removeFolderWorkspace( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error('Cannot delete the project root workspace. Remove the folder project instead.') + } + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. + await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { + // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly + // like the git paths — the local inventory must never reach a remote workspace's id. + // The resolved repo is authoritative here: path-derived metadata is shared by + // same-id host copies and can describe a different owner's workspace. + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + }) + await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { + await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts new file mode 100644 index 00000000000..2b81078b1dd --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -0,0 +1,248 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { + assertWorktreeCleanForRemoval, + listWorktreesStrict as listGitWorktreesStrict, + removeWorktree +} from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import { getWorktreeSharedLinkPaths } from '../../../git/worktree-shared-directories' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-removal-recovery' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + findRegisteredDeletableWorktree +} from '../../../worktree-removal-safety' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { + findExistingWorktreeSymlinkPaths, + removeWorktreeLinkedPaths +} from '../../worktree-symlinks' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { + formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, + isOrphanedWorktreeError +} from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + preservedBranchCleanupByScope, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export async function removeRegisteredLocalWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + hasLocalWorktreeGitOptions: boolean, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { + throw new Error( + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` + ) + } + try { + // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) + const ignoredLinkedPaths = args.force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. + } + + let removalResult: RemoveWorktreeResult | undefined + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath) + ) + let removalCompleted = false + try { + // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. + // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. + await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + + // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) + } + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } + removalResult = preserveBranchHeadFallback( + await withWorktreeRemoveStageSpan('git_remove', 'local', async () => + removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, removeOptions) + ), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + removalCompleted = true + return {} + } else { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + refreshedRegisteredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { + invalidateAuthorizedRootsCache() + }) + + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts new file mode 100644 index 00000000000..47297eda4d7 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts @@ -0,0 +1,97 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { getSshPtyProvider } from '../../pty' +import { + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' + +export async function removeRegisteredRemoteWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktree: GitWorktreeInfo, + removedPushTarget: GitPushTarget | undefined, + provider: SshGitProvider, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const remoteConnectionId = repo.connectionId! + // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. + if (!args.force) { + const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) + if (!clean) { + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error + } + } + + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'remote', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: remoteConnectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + rawRemovalResult = await withWorktreeRemoveStageSpan('git_remove', 'remote', async () => + Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, args.force) + ) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + const removalResult = preserveBranchHeadFallback(rawRemovalResult, registeredWorktree.head) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + await deleteRemoteWorktreeHistory(getSshPtyProvider(remoteConnectionId), args.worktreeId) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts new file mode 100644 index 00000000000..6cacdc6d317 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts @@ -0,0 +1,254 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaLeftoverDirectory, + canCleanupUnregisteredOrcaWorktreeDirectory, + canSafelyRemoveOrphanedWorktreeDirectory, + isDangerousWorktreeRemovalPath, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE, + UNREGISTERED_MISSING_WORKTREE_MESSAGE +} from '../../../worktree-removal-safety' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { + cleanupUnusedWorktreePushTargetRemote, + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import { getSshPtyProvider } from '../../pty' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { isAlreadyRemovedWorktreePath, isLocalGitRepository } from './worktree-removal-filesystem' + +export async function removeUnregisteredWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktrees: GitWorktreeInfo[], + removedMeta: WorktreeMeta | undefined, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + provider: SshGitProvider | null +): Promise { + const { mainWindow, store, runtime } = context + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + let canCleanOrphanedDirectory = false + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath, repo.connectionId) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: repo.connectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await fsProvider!.deletePath(worktreePath, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + 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), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + } + if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { + if (!args.force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. + if (repo.connectionId) { + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts new file mode 100644 index 00000000000..f59ac4a2ebb --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts @@ -0,0 +1,77 @@ +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' +import { getEffectiveHooksFromConfig } from '../../../effective-hook-config' +import { getEffectiveHooks, parseOrcaYaml } from '../../../hooks' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' +import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars' + +const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 + +export async function getArchiveHooksForRemoval(repo: Repo): Promise { + if (!repo.connectionId) { + return getEffectiveHooks(repo) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return getEffectiveHooksFromConfig(repo, null) + } + + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) + return getEffectiveHooksFromConfig(repo, yamlHooks) + } catch { + return getEffectiveHooksFromConfig(repo, null) + } +} + +export async function runRemoteArchiveHook( + repo: Repo, + worktreePath: string, + script: string +): Promise<{ success: boolean; output: string }> { + if (!repo.connectionId) { + return { success: true, output: '' } + } + + const provider = requireSshGitProvider(repo.connectionId) + const env = getSetupRunnerEnvVars(repo, worktreePath) + const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) + const result = await provider + .execNonInteractive( + isWindowsRemote ? 'cmd.exe' : '/bin/bash', + isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], + worktreePath, + WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, + undefined, + env + ) + .catch((error) => ({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: error instanceof Error ? error.message : String(error) + })) + const output = [ + result.stdout, + result.stderr, + result.spawnError, + result.timedOut ? 'archive hook timed out' : null, + typeof result.exitCode === 'number' && result.exitCode !== 0 + ? `archive hook exited ${result.exitCode}` + : null + ] + .filter((part): part is string => Boolean(part)) + .join('\n') + .trim() + + return { + success: !result.spawnError && !result.timedOut && result.exitCode === 0, + output + } +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts new file mode 100644 index 00000000000..3c52c4cabae --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts @@ -0,0 +1,26 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' + +export type WorktreeRemovalInFlight = { + optionsKey: string + promise: Promise +} + +export function getWorktreeRemovalOptionsKey( + args: Pick +): string { + const forceKey = args.force === true ? 'force' : 'normal' + const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' + // Why: a Force Delete retry must not coalesce onto the in-flight attempt that + // just failed the PTY gate — it would inherit that failure instead of retrying. + const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' + return `${forceKey}:${archiveKey}:${ptyKey}` +} + +export function getWorktreeRemovalInFlightKey( + worktreeId: string, + hostId?: ExecutionHostId +): string { + return `${hostId ?? ''}\0${worktreeId}` +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts new file mode 100644 index 00000000000..2f071f458c5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts @@ -0,0 +1,59 @@ +import type { Repo } from '../../../../shared/repo-types' +import { + getLocalWorktreePathAccess, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { gitExecFileAsync } from '../../../git/runner' +import { isWorktreePathMissing } from '../../../worktree-removal-safety' + +export async function isAlreadyRemovedWorktreePath( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) +} + +export async function isLocalGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +export 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}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts new file mode 100644 index 00000000000..ec2198bcdb0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -0,0 +1,106 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../../worktree-removal-repo-owner' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../../../localhost-worktree-label-proxy' +import { deleteWorktreeHistoryDir } from '../../../terminal-history-deletion' +import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordinator' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../../../workspace-cleanup-removal-snapshot-prune' +import { pruneWorkspaceCleanupScanSnapshot } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshot } from '../../../workspace-space-analysis-snapshot' + +export async function stopPtysForDestructiveWorktreeRemoval( + runtime: OrcaRuntimeService, + worktreeId: string, + options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} +): Promise { + const { connectionId, allowUnverifiedStop } = options + const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection — and the selector lookup this replaces + // throws `selector_ambiguous` the moment two hosts own the id. + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: provider, + onPtyStopped: clearProviderPtyState, + requirePhysicalStop: true, + // Why (#11960): set only by an explicit Force Delete, never by the ordinary + // confirmation — otherwise the gate would be off on the primary delete path. + ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } +} + +// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner +// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. +export function resolveWorktreeRemovalOwnerHostId( + store: Store, + worktreeId: string, + repo: Repo | undefined, + fallbackHostId?: ExecutionHostId +): ExecutionHostId | undefined { + return ( + fallbackHostId ?? + (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) + ) +} + +export function removeWorktreeMetadataAndTransientState( + store: Store, + worktreeId: string, + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string +): void { + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = getRepoIdFromWorktreeId(worktreeId) + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) + ) + // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } + if (!preservesSameIdOwner) { + advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) + // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. + deleteWorktreeHistoryDir(worktreeId) + // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). + pruneWorktreePRRefreshAliases(worktreeId) + } + // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. + const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } + void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) + void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) +} diff --git a/src/main/ipc/worktrees/repo-host-ownership.ts b/src/main/ipc/worktrees/repo-host-ownership.ts new file mode 100644 index 00000000000..89f26e871a5 --- /dev/null +++ b/src/main/ipc/worktrees/repo-host-ownership.ts @@ -0,0 +1,14 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence/loading-store/store' +import { resolveWorktreeRemovalRepoOwner } from '../../worktree-removal-repo-owner' + +export function resolveRepoForExecutionHost( + store: Store, + repoId: string, + hostId?: ExecutionHostId +): Repo | undefined { + // Why: host-qualified operations must never guess between repo owners; legacy unscoped calls work only for one unique owner. + const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) + return owner.kind === 'resolved' ? owner.repo : undefined +} diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts new file mode 100644 index 00000000000..5455a71d06e --- /dev/null +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -0,0 +1,16 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../../persistence/loading-store/store' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../../runtime/orca-runtime' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import type { WorktreeRemovalInFlight } from './removal/worktree-removal-coordinator' + +export type WorktreeIpcContext = { + mainWindow: BrowserWindow + store: Store + runtime: OrcaRuntimeService + options?: { + onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void + } + detectedWorktreeCancellations: SenderScopedRequestCancellations + worktreeRemovalsInFlight: Map +}