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