/* oxlint-disable max-lines */ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { readFile, rm } from 'fs/promises' import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { CreateWorktreeArgs, CreateWorktreeResult, DetectedWorktree, DetectedWorktreeListResult, GitPushTarget, GitWorktreeInfo, OrcaHooks, Repo, Worktree, WorktreeMeta } from '../../shared/types' import { buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree } from '../../shared/worktree-ownership' import { assertWorktreeCleanForRemoval, listWorktrees as listGitWorktrees, removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { withWorktreeSpan } from '../observability/instrumentation' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { getDefaultRemote } from '../git/repo' import { listRepoWorktrees } from '../repo-worktrees' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createIssueCommandRunnerScript, getEffectiveHooks, getEffectiveHooksFromConfig, getSetupRunnerEnvVars, loadHooks, parseOrcaYaml, readIssueCommand, runHook, hasHooksFile, hasUnrecognizedOrcaYamlKeys, writeIssueCommand } from '../hooks' import { mergeWorktree, parseWorktreeId, areWorktreePathsEqual, formatWorktreeRemovalError, isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from './worktree-logic' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { createLocalWorktree, createRemoteWorktree, cleanupUnusedWorktreePushTargetRemote, cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' import { invalidateAuthorizedRootsCache, isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { getLocalPtyProvider } from './pty' import { removeWorktreeSymlinks } from './worktree-symlinks' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, isWorktreePathMissing } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. // That makes them sort to the bottom of "Recent" even though the user just // added the repo / folder. Stamp discovery time the first time we see a // worktree so its very existence counts as a recency signal. Subsequent // list calls find the persisted meta and skip the stamp. function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) if (existing) { if (!existing.instanceId) { // Why: profiles created before lineage shipped already have WorktreeMeta // rows. Backfill on authoritative discovery so upgraded workspaces can // immediately participate in instance-validated lineage. return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) } return existing } return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) } async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise { if (!repo.connectionId) { return isWorktreePathMissing(worktreePath) } const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return false } return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) } function getWorktreeRemovalOptionsKey(args: { force?: boolean; skipArchive?: boolean }): string { const forceKey = args.force === true ? 'force' : 'normal' const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' return `${forceKey}:${archiveKey}` } 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 } const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { return } keySet.add(key) if (error) { console.warn(message, error) } else { console.warn(message) } } function rememberLocalWorktreeRoots( store: Store, repo: Repo, gitWorktrees: GitWorktreeInfo[] ): void { if (repo.connectionId) { return } // Why: worktrees:list already paid the `git worktree list` cost. Reusing // that result keeps later git/file IPC validation from doing a second // background scan that can trigger macOS folder-permission prompts. registerWorktreeRootsForRepo(store, repo.id, [ repo.path, ...gitWorktrees.map((worktree) => worktree.path) ]) } function pruneLineageForMissingRepoWorktrees( store: Store, repo: Repo, gitWorktrees: GitWorktreeInfo[] ): void { if ( typeof store.getAllWorktreeLineage !== 'function' || typeof store.removeWorktreeLineage !== 'function' ) { return } const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) const repoPrefix = `${repo.id}::` for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { // Why: path-derived IDs can disappear and later be reused by a different // checkout. Once a successful scan proves the child is gone, drop its // lineage so a future same-path worktree cannot inherit it. Missing // parents stay readable so the UI can show the repairable "Missing // parent" state. store.removeWorktreeLineage(childId) } if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: keep the child lineage so the UI can show "Missing parent", but // rotate the absent parent's stale identity once. If a different // checkout later reuses that path, the old lineage stays invalid. store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) } } } } type SshWorktreeMetaCandidate = { 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({ path: parsed.worktreePath, meta }) index.set(parsed.repoId, candidates) } return index } 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( repo: Repo, metaIndex: SshWorktreeMetaIndex ): ReturnType[] { const byWorktreeId = new Map>() for (const candidate of metaIndex.get(repo.id) ?? []) { const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, candidate.meta), candidate.meta ) 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 = repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) return gitWorktrees.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 }) if (!detected.visible) { return detected } meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return toDetectedWorktree({ repo, worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), meta, settings, knownOrcaLayouts, isLegacyRepoForVisibility }) }) } function stampAndMergeVisibleDetectedWorktree( store: Store, repo: Repo, detected: DetectedWorktree ) { const meta = resolveWorktreeMetaWithDiscoveryStamp(store, 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, 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, linkedGitLabMR: meta.linkedGitLabMR ?? null, linkedGitLabIssue: meta.linkedGitLabIssue ?? 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 } : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments } } 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 meta = existing?.instanceId ? existing : store.setWorktreeMeta(worktreeId, { instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId), ...(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() return listFolderWorkspaces(store, repo).map((worktree) => toDetectedWorktree({ repo, worktree, meta: store.getWorktreeMeta(worktree.id), settings, knownOrcaLayouts: [], isLegacyRepoForVisibility: true }) ) } function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { return buildFolderDetectedWorktrees(store, repo) .filter((worktree) => worktree.visible) .map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {})) }) } function createFolderWorkspace( args: CreateWorktreeArgs, repo: Repo, store: Store ): CreateWorktreeResult { const now = Date.now() const instanceId = randomUUID() const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) const meta = store.setWorktreeMeta(worktreeId, { instanceId, displayName: args.displayName || args.name, lastActivityAt: now, createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}) }) return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } } function buildDisconnectedDetectedWorktrees( store: Store, repo: Repo, worktrees: Worktree[] ): DetectedWorktree[] { const settings = store.getSettings() return worktrees.map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) const detected = toDetectedWorktree({ repo, worktree, meta, settings, knownOrcaLayouts: [], isLegacyRepoForVisibility: true }) return { ...detected, visible: true, ownership: detected.ownership === 'orca-managed' ? 'orca-managed' : 'unknown-legacy' } }) } export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService ): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:resolveMrBase') ipcMain.removeHandler('worktrees:remove') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:inspectSetupScriptImports') ipcMain.removeHandler('hooks:createIssueCommandRunner') ipcMain.removeHandler('hooks:readIssueCommand') ipcMain.removeHandler('hooks:writeIssueCommand') ipcMain.handle('worktrees:listAll', async () => { const repos = store.getRepos() const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) : new Map() // Why: repos are listed in parallel so total time = slowest repo, not // the sum of all repos. Each listRepoWorktrees spawns `git worktree list`. const results = await Promise.all( repos.map(async (repo) => { try { let gitWorktrees 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(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(repo, sshWorktreeMetaIndex) } } else { gitWorktrees = await listRepoWorktrees(repo) } 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 an empty success here. registerWorktreeRootsForRepo // would mark this repo as registered and flip // registeredWorktreeRootsDirty to false, which causes // resolveRegisteredWorktreePath to permanently deny access to // legitimate linked worktrees of this repo until something invalidates // the cache. Leaving it unregistered keeps the cache dirty so the // next access path can rebuild. return [] } }) ) return results.flat() }) 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 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(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(repo, sshWorktreeMetaIndex) } } else { gitWorktrees = await listRepoWorktrees(repo) } 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:listDetected', async (_event, args: { repoId: string }): Promise => { const repo = store.getRepo(args.repoId) if (!repo) { return { repoId: args.repoId, authoritative: false, source: 'metadata-fallback', worktrees: [] } } const sshWorktreeMetaIndex = repo.connectionId ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) : new Map() try { let gitWorktrees: GitWorktreeInfo[] if (isFolderRepo(repo)) { return { repoId: repo.id, authoritative: true, source: 'git', worktrees: buildFolderDetectedWorktrees(store, repo) } } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) } } gitWorktrees = await provider.listWorktrees(repo.path) } else { gitWorktrees = await listRepoWorktrees(repo) } 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) { 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(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: [] } } } ) ipcMain.handle( 'worktrees:create', async (_event, args: CreateWorktreeArgs): Promise => { // Why span here: worktree creation chains a clone-or-checkout, an // install hook, and several git invocations. Wrapping the IPC entry // gives every child git span a parent to attach to, so a failure in // step 3 of 5 still shows up in the trace tree alongside steps 1–2. // The branch name and remote URL are intentionally not added as // attributes — branch names can carry user-content (e.g. an issue // title) and the redactor would have to learn yet another rule; // the repo ID is the safer correlator for the bundle. 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' let result: CreateWorktreeResult try { // Why: only wrap the helpers themselves. The pre-validation throws // above (`Repo not found`, `Folder mode does not support creating // worktrees`) signal IPC-shape bugs, not the user-visible // git/filesystem failures the funnel cares about — bucketing them // into `unknown` would pollute the failure taxonomy. result = isFolderRepo(repo) ? createFolderWorkspace(args, repo, store) : repo.connectionId ? await createRemoteWorktree(args, repo, store, mainWindow) : await createLocalWorktree(args, repo, store, mainWindow, runtime) } catch (error) { track('workspace_create_failed', { source, error_class: classifyWorkspaceCreateError(error), ...getCohortAtEmit() }) throw error } // Why: emit `workspace_created` only after the underlying create has // resolved (the helpers throw on failure, so reaching this line means // git-add succeeded — we deliberately do not also emit a separate // `workspace_initialized`, see telemetry-plan.md§Deferred events). // `from_existing_branch` is true iff the caller specified a non-empty // baseBranch; an unspecified baseBranch means "branch from default // HEAD", which is the not-from-existing-branch case. We never send // the branch name itself. 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) } return result }) } ) ipcMain.handle( 'worktrees:resolvePrBase', async ( _event, args: { repoId: string prNumber: number headRefName?: string isCrossRepository?: boolean } ): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => { 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, { cwd: repo.path }) } 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) } return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, headRefName: args.headRefName, isCrossRepository: args.isCrossRepository, connectionId: repo.connectionId ?? null, gitExec, resolveRemote: async () => { if (repo.connectionId) { const { stdout } = await gitExec(['remote']) return ( stdout .split('\n') .map((line) => line.trim()) .find(Boolean) ?? 'origin' ) } return getDefaultRemote(repo.path) } }) } ) // Why: keep desktop IPC and mobile/runtime RPC on the same MR base // resolution path so SSH repos do not regress differently by surface. ipcMain.handle( 'worktrees:resolveMrBase', async ( _event, args: { repoId: string mrIid: number sourceBranch?: string isCrossRepository?: boolean } ): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => { return runtime.resolveManagedMrBase({ repoSelector: `id:${args.repoId}`, mrIid: args.mrIid, sourceBranch: args.sourceBranch, isCrossRepository: args.isCrossRepository }) } ) const worktreeRemovalsInFlight = new Map() ipcMain.handle( 'worktrees:remove', async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => { const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(args.worktreeId) if (inFlightRemoval) { if (inFlightRemoval.optionsKey === optionsKey) { return inFlightRemoval.promise } throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) } // Why: stale toast actions, double-clicks, and Space/sidebar races can // target the same worktree concurrently. Share the destructive backend // operation so only one path touches Git and the filesystem. const removal = (async (): Promise => { const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) const repo = store.getRepo(repoId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } if (isFolderRepo(repo)) { if (args.worktreeId === getFolderWorkspaceRootId(repo)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } // Why: folder workspaces share one filesystem root, so there is no Git // remove step to close shells; sweep PTYs before dropping metadata. await killAllProcessesForWorktree(args.worktreeId, { runtime, localProvider: getLocalPtyProvider() }).catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return } // Why: the renderer-supplied worktreeId contains a filesystem path. // Re-derive the canonical path from git before any destructive action. const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const registeredWorktrees = repo.connectionId ? await provider!.listWorktrees(repo.path) : await listGitWorktrees(repo.path) const removedMeta = store.getWorktreeMeta(args.worktreeId) const removedPushTarget = removedMeta?.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( repo.path, worktreePath, registeredWorktrees ) if (!registeredWorktree) { if (args.force && (await isAlreadyRemovedWorktreePath(repo, worktreePath))) { // Why: Force-delete can be retried from stale UI after a prior delete // already removed the directory and Git registration. Treat that as // successful cleanup, but do not delete any unregistered existing path. if (repo.connectionId) { await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, args.worktreeId, removedPushTarget, store ) } else { await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, removedPushTarget, store ) invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return } throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) } const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true // Run archive hook before removal so teardown scripts still see the worktree directory. const hooks = await getArchiveHooksForRemoval(repo) if (hooks?.scripts.archive && !args.skipArchive) { const result = repo.connectionId ? await runRemoteArchiveHook(repo, canonicalWorktreePath, hooks.scripts.archive) : await runHook('archive', canonicalWorktreePath, repo) if (!result.success) { console.error( `[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output ) } } if (repo.connectionId) { await (deleteBranch ? provider!.removeWorktree(canonicalWorktreePath, args.force) : provider!.removeWorktree(canonicalWorktreePath, args.force, { deleteBranch })) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, args.worktreeId, removedPushTarget, store ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return } // Why: `git worktree remove` (non-force) refuses to delete a worktree // that has untracked files, and a symlink pointing into the primary // checkout looks untracked to git. Unlink the user-configured symlinks // first so the normal delete path keeps working — otherwise every // deletion would require the Force Delete toast once the feature is on. if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await removeWorktreeSymlinks(canonicalWorktreePath, repo.symlinkPaths) } let shouldTearDownPtys = true try { await assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error( formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) } // Why: orphan cleanup does not need live shells to be killed first, // and preflight did not prove the worktree is cleanly removable. shouldTearDownPtys = false } if (shouldTearDownPtys) { // Why: once preflight proves normal deletion is clean, kill PTYs before // git-level removal so shells cannot keep the directory busy. await killAllProcessesForWorktree(args.worktreeId, { runtime, localProvider: getLocalPtyProvider() }) .then((r) => { const total = r.runtimeStopped + r.providerStopped + r.registryStopped if (total > 0) { console.info( `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` ) } }) .catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) } try { await (deleteBranch ? removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) : removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, { deleteBranch })) } catch (error) { // If git no longer tracks this worktree, clean up the directory and metadata if (isOrphanedWorktreeError(error)) { console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { await rm(canonicalWorktreePath, { recursive: true, force: true }).catch(() => {}) } else { console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` ) } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, removedPushTarget, store ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return } throw new Error( formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) } await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, removedPushTarget, store ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) })() worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: removal }) try { await removal } finally { if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === removal) { worktreeRemovalsInFlight.delete(args.worktreeId) } } } ) ipcMain.handle( 'worktrees:updateMeta', (_event, args: { worktreeId: string; updates: Partial }) => { const meta = store.setWorktreeMeta(args.worktreeId, args.updates) // Do NOT call notifyWorktreesChanged here. The renderer applies meta // updates optimistically before calling this IPC, so a notification // would trigger a redundant fetchWorktrees round-trip that bumps // sortEpoch and reorders the sidebar — the exact bug PR #209 tried // to fix (clicking a card would clear isUnread → updateMeta → // worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort). return meta } ) ipcMain.handle('worktrees:listLineage', async () => { await runtime.hydrateInferredWorktreeLineage() return store.getAllWorktreeLineage() }) 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: the renderer continuously snapshots the computed sidebar order into // sortOrder so that it can be restored on cold start (when ephemeral signals // like running jobs and live terminals are gone). A single batch call avoids // N individual updateMeta IPC round-trips; the persistence layer debounces // the actual disk write. ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { // Defensive: guard against malformed or missing input from the renderer. if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { return } const now = Date.now() for (let i = 0; i < args.orderedIds.length; i++) { // Descending timestamps so that the first item has the highest // sortOrder value (most recent), making b.sortOrder - a.sortOrder // a natural "first wins" comparator on cold start. store.setWorktreeMeta(args.orderedIds[i], { sortOrder: now - i * 1000 }) } }) ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) return { hasHooks: !result.isBinary, hooks: result.isBinary ? null : parseOrcaYaml(result.content), mayNeedUpdate: false } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } } const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null // Why: when a newer Orca version adds a top-level key to `orca.yaml`, older // versions that don't recognise it return null and show "could not be parsed". // Detecting well-formed but unrecognised keys lets the UI suggest updating // instead of implying the file is broken. const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) return { 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) } ) ipcMain.handle('hooks:inspectSetupScriptImports', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) 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 } }) }) ipcMain.handle('hooks:readIssueCommand', (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { return { localContent: null, sharedContent: null, effectiveContent: null, localFilePath: '', source: 'none' as const } } return readIssueCommand(repo.path) }) ipcMain.handle('hooks:writeIssueCommand', (_event, args: { repoId: string; content: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { return } writeIssueCommand(repo.path, args.content) }) }