mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
* Resolve and fetch the review target branch (compareBaseRef) during PR and MR worktree creation. * Persist this ref on worktree metadata instead of pinning the head SHA. * Dynamically repair existing worktrees with stale commit SHA compare bases in the Source Control UI using linked review metadata.
1821 lines
66 KiB
TypeScript
1821 lines
66 KiB
TypeScript
/* oxlint-disable max-lines */
|
||
import type { BrowserWindow } from 'electron'
|
||
import { ipcMain } from 'electron'
|
||
import { readFile, rm, stat } from 'fs/promises'
|
||
import { randomUUID } from 'crypto'
|
||
import type { Store } from '../persistence'
|
||
import { isFolderRepo } from '../../shared/repo-kind'
|
||
import {
|
||
isWorkspaceKey,
|
||
parseWorkspaceKey,
|
||
worktreeWorkspaceKey
|
||
} from '../../shared/workspace-scope'
|
||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||
import type {
|
||
CreateWorktreeArgs,
|
||
CreateWorktreeResult,
|
||
DetectedWorktree,
|
||
DetectedWorktreeListResult,
|
||
ForceDeleteWorktreeBranchResult,
|
||
GitHubPrStartPoint,
|
||
GitPushTarget,
|
||
GitWorktreeInfo,
|
||
OrcaHooks,
|
||
Repo,
|
||
RemoveWorktreeResult,
|
||
Worktree,
|
||
WorktreeMeta
|
||
} from '../../shared/types'
|
||
import {
|
||
buildKnownOrcaWorkspaceLayouts,
|
||
isLegacyRepoForExternalWorktreeVisibility,
|
||
toDetectedWorktree
|
||
} from '../../shared/worktree-ownership'
|
||
import {
|
||
assertWorktreeCleanForRemoval,
|
||
forceDeleteLocalBranch,
|
||
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 { fetchPrHeadTrackingRef } from '../github/pr-head-tracking-ref'
|
||
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 { closeLocalWatcherForWorktreePath } from './filesystem-watcher'
|
||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||
import { killAllProcessesForWorktree } from '../runtime/worktree-teardown'
|
||
import { clearProviderPtyState, 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 { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||
import {
|
||
assertWorktreeDoesNotContainRegisteredWorktree,
|
||
canCleanupUnregisteredOrcaWorktreeDirectory,
|
||
canSafelyRemoveOrphanedWorktreeDirectory,
|
||
findRegisteredDeletableWorktree,
|
||
isWorktreePathMissing,
|
||
ORPHANED_WORKTREE_DIRECTORY_MESSAGE,
|
||
stripOrcaProvenanceMetaUpdates,
|
||
UNREGISTERED_MISSING_WORKTREE_MESSAGE
|
||
} 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'
|
||
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
|
||
|
||
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
|
||
const WORKTREE_LIST_ALL_CONCURRENCY = 8
|
||
|
||
async function mapWithConcurrency<T, R>(
|
||
items: readonly T[],
|
||
limit: number,
|
||
fn: (item: T) => Promise<R>
|
||
): Promise<R[]> {
|
||
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
|
||
}
|
||
|
||
function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: string): void {
|
||
// Why: worktree IDs are path-derived and can be recreated, so removal must
|
||
// drop process-local caches before the same ID can point at a new workspace.
|
||
store.removeWorktreeMeta(worktreeId)
|
||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||
deleteWorktreeHistoryDir(worktreeId)
|
||
}
|
||
|
||
async function closeLocalWatcherForRemoval(worktreePath: string): Promise<void> {
|
||
await closeLocalWatcherForWorktreePath(worktreePath).catch((err) => {
|
||
console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err)
|
||
})
|
||
}
|
||
|
||
function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeInfo[] {
|
||
const uniqueGitWorktrees: GitWorktreeInfo[] = []
|
||
for (const gitWorktree of gitWorktrees) {
|
||
if (
|
||
uniqueGitWorktrees.some((existing) => areWorktreePathsEqual(existing.path, gitWorktree.path))
|
||
) {
|
||
continue
|
||
}
|
||
uniqueGitWorktrees.push(gitWorktree)
|
||
}
|
||
return uniqueGitWorktrees
|
||
}
|
||
|
||
function getProjectHostSetupMetaUpdates(
|
||
store: Store,
|
||
repo: Repo,
|
||
existing?: WorktreeMeta
|
||
): Partial<Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'>> {
|
||
const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||
const sameSetup =
|
||
existing?.projectHostSetupId === undefined ||
|
||
existing.projectHostSetupId === ownership.projectHostSetupId
|
||
return {
|
||
// Why: project IDs can be upgraded from legacy repo IDs to provider-backed
|
||
// logical IDs. If the host setup is the same, repair ownership on discovery.
|
||
...(sameSetup && existing?.projectId !== ownership.projectId
|
||
? { projectId: ownership.projectId }
|
||
: {}),
|
||
...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}),
|
||
...(existing?.projectHostSetupId === undefined
|
||
? { projectHostSetupId: ownership.projectHostSetupId }
|
||
: {})
|
||
}
|
||
}
|
||
|
||
// 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. The same authoritative discovery pass is also the
|
||
// safest time to backfill project-host setup ownership for upgraded profiles.
|
||
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: profiles created before lineage shipped already have WorktreeMeta
|
||
// rows. Backfill on authoritative discovery so upgraded workspaces can
|
||
// immediately participate in instance-validated 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): 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<RemoveWorktreeResult>
|
||
}
|
||
|
||
type PreservedBranchCleanupTarget = {
|
||
branchName: string
|
||
head: string
|
||
pushTarget?: GitPushTarget
|
||
}
|
||
|
||
const preservedBranchCleanupByWorktreeId = new Map<string, PreservedBranchCleanupTarget>()
|
||
|
||
function rememberPreservedBranchCleanupTarget(
|
||
worktreeId: string,
|
||
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.`
|
||
)
|
||
}
|
||
preservedBranchCleanupByWorktreeId.set(worktreeId, {
|
||
branchName: result.preservedBranch.branchName,
|
||
head,
|
||
...(pushTarget ? { pushTarget } : {})
|
||
})
|
||
return
|
||
}
|
||
preservedBranchCleanupByWorktreeId.delete(worktreeId)
|
||
}
|
||
|
||
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
|
||
): PreservedBranchCleanupTarget {
|
||
const target = preservedBranchCleanupByWorktreeId.get(worktreeId)
|
||
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<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 childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) {
|
||
const childScope = parseWorkspaceKey(childWorkspaceKey)
|
||
if (
|
||
childScope?.type === 'worktree' &&
|
||
childScope.worktreeId.startsWith(repoPrefix) &&
|
||
!liveIds.has(childScope.worktreeId)
|
||
) {
|
||
if (isWorkspaceKey(childWorkspaceKey)) {
|
||
store.removeWorkspaceLineage?.(childWorkspaceKey)
|
||
}
|
||
}
|
||
}
|
||
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)
|
||
store.removeWorkspaceLineage?.(worktreeWorkspaceKey(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 = {
|
||
id: string
|
||
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({ id: worktreeId, 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(
|
||
store: Store,
|
||
repo: Repo,
|
||
metaIndex: SshWorktreeMetaIndex
|
||
): ReturnType<typeof mergeWorktree>[] {
|
||
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
|
||
for (const candidate of metaIndex.get(repo.id) ?? []) {
|
||
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)
|
||
}
|
||
const worktree = mergeWorktree(
|
||
repo.id,
|
||
synthesizeSshGitWorktree(repo, candidate.path, meta),
|
||
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 = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||
const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo)
|
||
return dedupeGitWorktreesByPath(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 = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, 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 = 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,
|
||
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,
|
||
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()
|
||
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)
|
||
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: CreateWorktreeArgs,
|
||
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',
|
||
...(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 } : {})
|
||
})
|
||
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:prefetchCreateBase')
|
||
ipcMain.removeHandler('worktrees:resolvePrBase')
|
||
ipcMain.removeHandler('worktrees:resolveMrBase')
|
||
ipcMain.removeHandler('worktrees:remove')
|
||
ipcMain.removeHandler('worktrees:forceDeletePreservedBranch')
|
||
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: each local repo listing can spawn `git worktree list`; cap fan-out
|
||
// so large repo fleets don't start unbounded subprocesses at once.
|
||
const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, 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(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 {
|
||
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(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 {
|
||
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(store, 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(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: [] }
|
||
}
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'worktrees:prefetchCreateBase',
|
||
async (_event, args: { repoId: string; baseBranch?: string }): Promise<void> => {
|
||
const repo = store.getRepo(args.repoId)
|
||
if (!repo) {
|
||
return
|
||
}
|
||
try {
|
||
await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime })
|
||
} catch {
|
||
// Why: this is an optimistic warm-up. The actual create path still
|
||
// awaits the same refresh and reports user-visible failures there.
|
||
}
|
||
}
|
||
)
|
||
|
||
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
|
||
baseRefName?: string
|
||
isCrossRepository?: boolean
|
||
}
|
||
): Promise<GitHubPrStartPoint | { 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)
|
||
}
|
||
// Why: SSH repos can't fetch over the relay's read-only git.exec channel, so
|
||
// route the PR head fetch through the write-capable helper instead of gitExec.
|
||
const fetchRemoteTrackingRef = (remote: string, branch: string): Promise<void> =>
|
||
fetchPrHeadTrackingRef(
|
||
repo,
|
||
repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined,
|
||
remote,
|
||
branch
|
||
)
|
||
|
||
return resolveGitHubPrStartPoint({
|
||
repoPath: repo.path,
|
||
prNumber: args.prNumber,
|
||
headRefName: args.headRefName,
|
||
baseRefName: args.baseRefName,
|
||
isCrossRepository: args.isCrossRepository,
|
||
connectionId: repo.connectionId ?? null,
|
||
gitExec,
|
||
fetchRemoteTrackingRef,
|
||
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
|
||
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<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<RemoveWorktreeResult> => {
|
||
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(),
|
||
onPtyStopped: clearProviderPtyState
|
||
}).catch((err) => {
|
||
console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err)
|
||
})
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
preservedBranchCleanupByWorktreeId.delete(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) {
|
||
const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null
|
||
let canCleanOrphanedDirectory = false
|
||
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo)
|
||
if (
|
||
canCleanupUnregisteredOrcaWorktreeDirectory({
|
||
meta: removedMeta,
|
||
worktreePath,
|
||
repo,
|
||
knownOrcaLayouts
|
||
})
|
||
) {
|
||
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 {
|
||
canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory(
|
||
worktreePath,
|
||
repo.path
|
||
)
|
||
}
|
||
}
|
||
if (canCleanOrphanedDirectory) {
|
||
assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees)
|
||
if (!args.force) {
|
||
throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE)
|
||
}
|
||
if (repo.connectionId) {
|
||
await fsProvider!.deletePath(worktreePath, true)
|
||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||
provider!,
|
||
repo.path,
|
||
args.worktreeId,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
} else {
|
||
await closeLocalWatcherForRemoval(worktreePath)
|
||
await rm(worktreePath, { recursive: true, force: true })
|
||
await cleanupUnusedWorktreePushTargetRemote(
|
||
repo.path,
|
||
args.worktreeId,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
invalidateAuthorizedRootsCache()
|
||
}
|
||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
preservedBranchCleanupByWorktreeId.delete(args.worktreeId)
|
||
notifyWorktreesChanged(mainWindow, repoId)
|
||
return {}
|
||
}
|
||
if (await isAlreadyRemovedWorktreePath(repo, worktreePath)) {
|
||
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 from Git and disk.
|
||
// The sidebar delete action has persisted metadata proving this was
|
||
// an Orca-known row, so no force confirmation is needed.
|
||
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)
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
preservedBranchCleanupByWorktreeId.delete(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
|
||
|
||
let shouldTearDownPtys = 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) {
|
||
// Why: SSH deletion mirrors the local flow: hooks run while the
|
||
// directory is intact, then the clean check guards destructive 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 rawRemovalResult = await (deleteBranch
|
||
? provider!.removeWorktree(canonicalWorktreePath, args.force)
|
||
: provider!.removeWorktree(canonicalWorktreePath, args.force, { deleteBranch }))
|
||
const removalResult = preserveBranchHeadFallback(
|
||
rawRemovalResult,
|
||
registeredWorktree.head
|
||
)
|
||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||
provider!,
|
||
repo.path,
|
||
args.worktreeId,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
rememberPreservedBranchCleanupTarget(
|
||
args.worktreeId,
|
||
removalResult,
|
||
registeredWorktree.head,
|
||
removedPushTarget
|
||
)
|
||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
notifyWorktreesChanged(mainWindow, repoId)
|
||
return removalResult ?? {}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
await closeLocalWatcherForRemoval(canonicalWorktreePath)
|
||
|
||
if (shouldTearDownPtys) {
|
||
// Why: once preflight proves normal deletion is clean, kill PTYs before
|
||
// git-level removal so Windows handles cannot keep the directory busy.
|
||
await killAllProcessesForWorktree(args.worktreeId, {
|
||
runtime,
|
||
localProvider: getLocalPtyProvider(),
|
||
onPtyStopped: clearProviderPtyState
|
||
})
|
||
.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)
|
||
})
|
||
}
|
||
|
||
let removalResult: RemoveWorktreeResult | undefined
|
||
try {
|
||
removalResult = preserveBranchHeadFallback(
|
||
await (deleteBranch
|
||
? removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, {
|
||
// Why: this handler already paid for an authoritative worktree
|
||
// list to validate the target; reuse it instead of rescanning
|
||
// every sibling worktree during the hot delete path.
|
||
knownRemovedWorktree: registeredWorktree
|
||
})
|
||
: removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, {
|
||
deleteBranch,
|
||
knownRemovedWorktree: registeredWorktree
|
||
})),
|
||
registeredWorktree.head
|
||
)
|
||
} 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 closeLocalWatcherForRemoval(canonicalWorktreePath)
|
||
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)
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
preservedBranchCleanupByWorktreeId.delete(args.worktreeId)
|
||
invalidateAuthorizedRootsCache()
|
||
notifyWorktreesChanged(mainWindow, repoId)
|
||
return {}
|
||
}
|
||
throw new Error(
|
||
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
|
||
)
|
||
}
|
||
await cleanupUnusedWorktreePushTargetRemote(
|
||
repo.path,
|
||
args.worktreeId,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
rememberPreservedBranchCleanupTarget(
|
||
args.worktreeId,
|
||
removalResult,
|
||
registeredWorktree.head,
|
||
removedPushTarget
|
||
)
|
||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||
invalidateAuthorizedRootsCache()
|
||
|
||
notifyWorktreesChanged(mainWindow, repoId)
|
||
return removalResult ?? {}
|
||
})()
|
||
worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: removal })
|
||
try {
|
||
return await removal
|
||
} finally {
|
||
if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === removal) {
|
||
worktreeRemovalsInFlight.delete(args.worktreeId)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'worktrees:forceDeletePreservedBranch',
|
||
async (
|
||
_event,
|
||
args: { worktreeId: string; branchName: string; expectedHead: string }
|
||
): Promise<ForceDeleteWorktreeBranchResult> => {
|
||
const { repoId } = parseWorktreeId(args.worktreeId)
|
||
const cleanupTarget = getPreservedBranchCleanupTarget(
|
||
args.worktreeId,
|
||
args.branchName,
|
||
args.expectedHead
|
||
)
|
||
const repo = store.getRepo(repoId)
|
||
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)
|
||
await forceDeleteLocalBranch(
|
||
repo.path,
|
||
cleanupTarget.branchName,
|
||
cleanupTarget.head,
|
||
(argv, cwd) => provider.exec(argv, cwd)
|
||
)
|
||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||
provider,
|
||
repo.path,
|
||
args.worktreeId,
|
||
cleanupTarget.pushTarget,
|
||
store
|
||
)
|
||
} else {
|
||
await forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)
|
||
await cleanupUnusedWorktreePushTargetRemote(
|
||
repo.path,
|
||
args.worktreeId,
|
||
cleanupTarget.pushTarget,
|
||
store
|
||
)
|
||
}
|
||
|
||
preservedBranchCleanupByWorktreeId.delete(args.worktreeId)
|
||
return { deleted: true }
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'worktrees:updateMeta',
|
||
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
|
||
const updates =
|
||
args.updates.displayName !== undefined
|
||
? {
|
||
...args.updates,
|
||
pendingFirstAgentMessageRename: false,
|
||
firstAgentMessageRenameError: null
|
||
}
|
||
: args.updates
|
||
const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(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 {
|
||
lineage: store.getAllWorktreeLineage(),
|
||
workspaceLineage: store.getAllWorkspaceLineage()
|
||
}
|
||
})
|
||
|
||
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 { 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: 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 {
|
||
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)
|
||
}
|
||
)
|
||
|
||
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
|
||
}
|
||
},
|
||
{
|
||
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 }) => {
|
||
const repo = store.getRepo(args.repoId)
|
||
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 }) => {
|
||
const repo = store.getRepo(args.repoId)
|
||
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)
|
||
}
|
||
)
|
||
}
|