mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
471 lines
19 KiB
TypeScript
471 lines
19 KiB
TypeScript
/* oxlint-disable max-lines */
|
|
import type { BrowserWindow } from 'electron'
|
|
import { ipcMain } from 'electron'
|
|
import { rm } from 'fs/promises'
|
|
import type { Store } from '../persistence'
|
|
import { isFolderRepo } from '../../shared/repo-kind'
|
|
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
|
import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types'
|
|
import { removeWorktree } from '../git/worktree'
|
|
import { gitExecFileAsync } from '../git/runner'
|
|
import { getDefaultRemote } from '../git/repo'
|
|
import { getWorkItem } from '../github/client'
|
|
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
|
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
|
import {
|
|
createIssueCommandRunnerScript,
|
|
getEffectiveHooks,
|
|
loadHooks,
|
|
readIssueCommand,
|
|
runHook,
|
|
hasHooksFile,
|
|
hasUnrecognizedOrcaYamlKeys,
|
|
writeIssueCommand
|
|
} from '../hooks'
|
|
import {
|
|
mergeWorktree,
|
|
parseWorktreeId,
|
|
formatWorktreeRemovalError,
|
|
isOrphanedWorktreeError
|
|
} from './worktree-logic'
|
|
import {
|
|
createLocalWorktree,
|
|
createRemoteWorktree,
|
|
notifyWorktreesChanged
|
|
} from './worktree-remote'
|
|
import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } 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 { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events'
|
|
|
|
// 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) {
|
|
return existing
|
|
}
|
|
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
|
|
}
|
|
|
|
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:create')
|
|
ipcMain.removeHandler('worktrees:resolvePrBase')
|
|
ipcMain.removeHandler('worktrees:remove')
|
|
ipcMain.removeHandler('worktrees:updateMeta')
|
|
ipcMain.removeHandler('worktrees:persistSortOrder')
|
|
ipcMain.removeHandler('hooks:check')
|
|
ipcMain.removeHandler('hooks:createIssueCommandRunner')
|
|
ipcMain.removeHandler('hooks:readIssueCommand')
|
|
ipcMain.removeHandler('hooks:writeIssueCommand')
|
|
|
|
ipcMain.handle('worktrees:listAll', async () => {
|
|
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
|
|
// listing git worktrees when the cache is already fresh — the handler
|
|
// itself calls listWorktrees for every repo below.
|
|
await ensureAuthorizedRootsCache(store)
|
|
const repos = store.getRepos()
|
|
|
|
// 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)) {
|
|
gitWorktrees = [createFolderWorktree(repo)]
|
|
} else if (repo.connectionId) {
|
|
const provider = getSshGitProvider(repo.connectionId)
|
|
if (!provider) {
|
|
return []
|
|
}
|
|
gitWorktrees = await provider.listWorktrees(repo.path)
|
|
} else {
|
|
gitWorktrees = await listRepoWorktrees(repo)
|
|
}
|
|
return gitWorktrees.map((gw) => {
|
|
const worktreeId = `${repo.id}::${gw.path}`
|
|
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
|
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
|
})
|
|
} catch {
|
|
return []
|
|
}
|
|
})
|
|
)
|
|
|
|
return results.flat()
|
|
})
|
|
|
|
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
|
|
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
|
|
// listing git worktrees when the cache is already fresh — the handler
|
|
// itself calls listWorktrees below.
|
|
await ensureAuthorizedRootsCache(store)
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
return []
|
|
}
|
|
|
|
let gitWorktrees
|
|
if (isFolderRepo(repo)) {
|
|
gitWorktrees = [createFolderWorktree(repo)]
|
|
} else if (repo.connectionId) {
|
|
const provider = getSshGitProvider(repo.connectionId)
|
|
// Why: when SSH is disconnected the provider is null. Return [] so the
|
|
// renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`)
|
|
// preserves its cached worktree list. This avoids a console error on every
|
|
// fetchAllWorktrees cycle while the connection is being (re-)established —
|
|
// worktrees will be properly populated when the SSH `connected` event fires
|
|
// and triggers a re-fetch.
|
|
if (!provider) {
|
|
return []
|
|
}
|
|
gitWorktrees = await provider.listWorktrees(repo.path)
|
|
} else {
|
|
gitWorktrees = await listRepoWorktrees(repo)
|
|
}
|
|
return gitWorktrees.map((gw) => {
|
|
const worktreeId = `${repo.id}::${gw.path}`
|
|
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
|
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
|
})
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'worktrees:create',
|
|
async (_event, args: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
throw new Error(`Repo not found: ${args.repoId}`)
|
|
}
|
|
if (isFolderRepo(repo)) {
|
|
throw new Error('Folder mode does not support creating worktrees.')
|
|
}
|
|
|
|
// Remote repos route all git operations through the relay
|
|
const result = repo.connectionId
|
|
? await createRemoteWorktree(args, repo, store, mainWindow)
|
|
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
|
|
|
// 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.
|
|
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
|
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
|
track('workspace_created', {
|
|
source,
|
|
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0
|
|
})
|
|
|
|
return result
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'worktrees:resolvePrBase',
|
|
async (
|
|
_event,
|
|
args: {
|
|
repoId: string
|
|
prNumber: number
|
|
headRefName?: string
|
|
isCrossRepository?: boolean
|
|
}
|
|
): Promise<{ baseBranch: string } | { error: string }> => {
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
return { error: 'Repo not found' }
|
|
}
|
|
// Why: remote SSH repos are out of scope in v1. The picker already
|
|
// disables its PR tab for them — this guard belt-and-suspenders it.
|
|
if (repo.connectionId) {
|
|
return { error: 'PR start points are not supported for remote repos yet.' }
|
|
}
|
|
if (isFolderRepo(repo)) {
|
|
return { error: 'Folder mode does not support creating worktrees.' }
|
|
}
|
|
|
|
let headRefName = args.headRefName?.trim() ?? ''
|
|
let isCrossRepository = args.isCrossRepository === true
|
|
|
|
// Skip the gh lookup when both hints are present (picker already has them).
|
|
if (!headRefName) {
|
|
// Why: the caller already knows this is a PR number, so scope the
|
|
// lookup to `type: 'pr'` and skip the speculative issue-first probe
|
|
// that would hit the upstream issue tracker for fork checkouts.
|
|
const item = await getWorkItem(repo.path, args.prNumber, 'pr')
|
|
if (!item || item.type !== 'pr') {
|
|
return { error: `PR #${args.prNumber} not found.` }
|
|
}
|
|
headRefName = (item.branchName ?? '').trim()
|
|
if (!headRefName) {
|
|
return { error: `PR #${args.prNumber} has no head branch.` }
|
|
}
|
|
if (item.isCrossRepository === true) {
|
|
isCrossRepository = true
|
|
}
|
|
}
|
|
|
|
let remote: string
|
|
try {
|
|
remote = await getDefaultRemote(repo.path)
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
|
}
|
|
|
|
// Why: fork PR heads live on a remote we don't have configured, so
|
|
// `git fetch <remote> <headRefName>` would fail. GitHub exposes every
|
|
// PR head (fork or same-repo) as refs/pull/<N>/head on the upstream
|
|
// repo. Fetch that and snapshot the SHA — the new worktree branch is
|
|
// derived from the workspace name, so there's no tracking ref to set
|
|
// up, which makes SHA semantics ("branch from this commit") cleaner
|
|
// than returning a ref that would go stale on force-push.
|
|
if (isCrossRepository) {
|
|
const pullRef = `refs/pull/${args.prNumber}/head`
|
|
try {
|
|
await gitExecFileAsync(['fetch', remote, pullRef], { cwd: repo.path })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return {
|
|
error: `Failed to fetch ${pullRef}: ${message.split('\n')[0]}`
|
|
}
|
|
}
|
|
let sha: string
|
|
try {
|
|
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], {
|
|
cwd: repo.path
|
|
})
|
|
sha = stdout.trim()
|
|
} catch {
|
|
return { error: `Could not resolve fork PR #${args.prNumber} head after fetch.` }
|
|
}
|
|
if (!sha) {
|
|
return { error: `Empty SHA resolving fork PR #${args.prNumber} head.` }
|
|
}
|
|
return { baseBranch: sha }
|
|
}
|
|
|
|
try {
|
|
await gitExecFileAsync(['fetch', remote, headRefName], { cwd: repo.path })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return {
|
|
error: `Failed to fetch ${remote}/${headRefName}: ${message.split('\n')[0]}`
|
|
}
|
|
}
|
|
|
|
const remoteRef = `${remote}/${headRefName}`
|
|
try {
|
|
await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path })
|
|
} catch {
|
|
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
|
|
}
|
|
|
|
return { baseBranch: remoteRef }
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'worktrees:remove',
|
|
async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => {
|
|
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
|
|
const repo = store.getRepo(repoId)
|
|
if (!repo) {
|
|
throw new Error(`Repo not found: ${repoId}`)
|
|
}
|
|
if (isFolderRepo(repo)) {
|
|
throw new Error('Folder mode does not support deleting worktrees.')
|
|
}
|
|
|
|
// Why: kill every PTY belonging to this worktree BEFORE git-level
|
|
// removal. The renderer pre-kills via shutdownWorktreeTerminals, but
|
|
// defensive teardown here protects against: (a) a future renderer bug,
|
|
// (b) a disconnected window, (c) an out-of-band window.api.worktrees.remove
|
|
// caller. Placement is before the SSH early-return so local-host PTYs
|
|
// are still reaped for local repos; SSH-backed PTYs are handled by the
|
|
// remote provider's own teardown (design §4.3, §6).
|
|
if (!repo.connectionId) {
|
|
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)
|
|
})
|
|
}
|
|
|
|
if (repo.connectionId) {
|
|
const provider = getSshGitProvider(repo.connectionId)
|
|
if (!provider) {
|
|
throw new Error(`No git provider for connection "${repo.connectionId}"`)
|
|
}
|
|
await provider.removeWorktree(worktreePath, args.force)
|
|
store.removeWorktreeMeta(args.worktreeId)
|
|
deleteWorktreeHistoryDir(args.worktreeId)
|
|
notifyWorktreesChanged(mainWindow, repoId)
|
|
return
|
|
}
|
|
|
|
// Run archive hook before removal
|
|
const hooks = getEffectiveHooks(repo)
|
|
if (hooks?.scripts.archive && !args.skipArchive) {
|
|
const result = await runHook('archive', worktreePath, repo)
|
|
if (!result.success) {
|
|
console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output)
|
|
}
|
|
}
|
|
|
|
// 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(worktreePath, repo.symlinkPaths)
|
|
}
|
|
|
|
try {
|
|
await removeWorktree(repo.path, worktreePath, args.force ?? false)
|
|
} 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 ${worktreePath}, cleaning up`)
|
|
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
|
|
// 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(() => {})
|
|
store.removeWorktreeMeta(args.worktreeId)
|
|
deleteWorktreeHistoryDir(args.worktreeId)
|
|
await rebuildAuthorizedRootsCache(store)
|
|
notifyWorktreesChanged(mainWindow, repoId)
|
|
return
|
|
}
|
|
throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false))
|
|
}
|
|
store.removeWorktreeMeta(args.worktreeId)
|
|
deleteWorktreeHistoryDir(args.worktreeId)
|
|
await rebuildAuthorizedRootsCache(store)
|
|
|
|
notifyWorktreesChanged(mainWindow, repoId)
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
)
|
|
|
|
// 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', (_event, args: { repoId: string }) => {
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo || isFolderRepo(repo)) {
|
|
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: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)
|
|
})
|
|
}
|