1279 lines
47 KiB
TypeScript
1279 lines
47 KiB
TypeScript
/* 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<boolean> {
|
||
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<OrcaHooks | null> {
|
||
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<void>
|
||
}
|
||
|
||
const loggedUnavailableSshGitProviders = new Set<string>()
|
||
const loggedWorktreeListFailures = new Set<string>()
|
||
const loggedMalformedWorktreeMetaKeys = new Set<string>()
|
||
|
||
function warnOnce(keySet: Set<string>, 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<string, SshWorktreeMetaCandidate[]>
|
||
|
||
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<typeof mergeWorktree>[] {
|
||
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
|
||
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<DetectedWorktreeListResult> => {
|
||
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<CreateWorktreeResult> => {
|
||
// 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<string, WorktreeRemovalInFlight>()
|
||
|
||
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<void> => {
|
||
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/<name>`) 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<WorktreeMeta> }) => {
|
||
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)
|
||
})
|
||
}
|