Files
orca/src/main/runtime/runtime-reference-catalog.ts
T
Neil 15717a4e53 Add CLI commands to manage and find workspace references (#26807)
* Add CLI commands to manage and find workspace references

* Fix reference review findings: source-context twins, legacy link collapse, closed chats, old-host errors

- Keep same-URL links with different source contexts separate; CLI add/remove no longer drops a twin
- Restore merging a bare legacy link into its single matching URL link (no duplicate PR rows, no resurrection on older-client removal)
- Carry absorbed data through collapse chains; resolve repoId conflicts deterministically
- Identity: ignore www., case-insensitive GitHub/GitLab/Gitea paths (stored URL keeps case), no Jira match on git hosts; find accepts self-hosted issue URLs
- reference find: skip closed native chats, require a session id for linked, include an explicitly named archived workspace, warm-cache fallback for --worktree
- Selector parity: collapse same-host duplicate paths, issue: ignores unlinked rows
- CLI: clear update-host error for hosts without reference.*, local flag checks before host probe, help text fixes

* Fix CI: add reference rm alias for verb policy; expect linkedItems in worktree ps summary

* Simplify reference CLI: share identifier/legacy-match helpers, drop redundant dedupe
2026-10-09 22:22:01 -07:00

201 lines
7.1 KiB
TypeScript

import type { RuntimeStore } from './runtime-store-contract'
import type {
ReferenceWorkspace,
RuntimeReferenceListResult
} from '../../shared/runtime-reference-contracts'
import type { Worktree } from '../../shared/worktree/types'
import { mergeWorktree } from '../ipc/worktree-metadata-merge'
import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { splitWorktreeIdForFilesystem, worktreeIdsEqual } from '../../shared/worktree/id'
import { getWorktreeHostIdentity } from '../../shared/worktree/host-qualified-identity'
import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree'
import {
getWorkspaceAttachments,
matchesLegacyWorkspaceAttachment
} from '../../shared/workspace-attachments'
import { legacyWorkspaceAttachments } from '../../shared/workspace-attachment-legacy'
import { getWorkspaceReferenceIdentity } from '../../shared/workspace-reference-identity'
import {
isPathInsideOrEqual,
isWslUncPathForCallerLinuxPath,
isWslUncPathForLinuxMountedPath
} from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { branchSelectorMatches, runtimePathsEqual } from './runtime-worktree-path-identity'
export function listReferenceWorkspaces(
store: Pick<
RuntimeStore,
| 'getRepos'
| 'getAllWorktreeMeta'
| 'getAllWorktreeMetaForHost'
| 'getProjectGroups'
| 'getFolderWorkspaces'
>,
cached: readonly Worktree[] = []
): ReferenceWorkspace[] {
const repos = store.getRepos()
const ownerCounts = new Map<string, number>()
for (const repo of repos) {
ownerCounts.set(repo.id, (ownerCounts.get(repo.id) ?? 0) + 1)
}
const snapshots = new Map(
[...new Set(repos.map(getRepoExecutionHostId))].map((host) => [
host,
readAllWorktreeMetaForHost(store, host)
])
)
const cachedByIdentity = new Map(cached.map((row) => [getWorktreeHostIdentity(row), row]))
const reposByIdentity = new Map(
repos.map((repo) => [
getWorktreeHostIdentity({
id: repo.id,
hostId: getRepoExecutionHostId(repo)
}),
repo
])
)
const result: ReferenceWorkspace[] = []
for (const [hostId, metadata] of snapshots) {
for (const [id, meta] of Object.entries(metadata)) {
const parsed = splitWorktreeIdForFilesystem(id)
if (!parsed) {
continue
}
const repo = reposByIdentity.get(getWorktreeHostIdentity({ id: parsed.repoId, hostId }))
if (!repo) {
continue
}
if (!meta.hostId && (ownerCounts.get(repo.id) ?? 0) > 1) {
continue
}
const previous = cachedByIdentity.get(getWorktreeHostIdentity({ id, hostId }))
const worktree = mergeWorktree(
repo.id,
{
path: parsed.worktreePath,
head: previous?.head ?? '',
branch: previous?.branch ?? '',
isBare: previous?.isBare ?? false,
isMainWorktree: runtimePathsEqual(parsed.worktreePath, repo.path)
},
{ ...meta, hostId },
repo.displayName
)
result.push({
...worktree,
id,
kind: 'worktree',
name: worktree.displayName,
repo: repo.displayName,
linkedItems: getWorkspaceAttachments(worktree)
})
}
}
const known = new Set(result.map(getWorktreeHostIdentity))
for (const worktree of cached) {
const repo = reposByIdentity.get(
getWorktreeHostIdentity({ id: worktree.repoId, hostId: worktree.hostId })
)
if (!repo || known.has(getWorktreeHostIdentity(worktree))) {
continue
}
result.push({
...worktree,
kind: 'worktree',
name: worktree.displayName,
repo: repo.displayName,
linkedItems: getWorkspaceAttachments(worktree)
})
}
const groups = new Map((store.getProjectGroups?.() ?? []).map((group) => [group.id, group]))
for (const folder of store.getFolderWorkspaces?.() ?? []) {
const worktree = folderWorkspaceToWorktree(folder)
result.push({
...worktree,
kind: 'folder',
name: folder.name,
repo: groups.get(folder.projectGroupId)?.name ?? folder.projectGroupId,
linkedItems: getWorkspaceAttachments(worktree)
})
}
return result
}
export function listWorkspaceReferences(worktree: ReferenceWorkspace): RuntimeReferenceListResult {
const selected = legacyWorkspaceAttachments(worktree)
return {
worktree,
references: getWorkspaceAttachments(worktree).map((item) => ({
...item,
key: getWorkspaceReferenceIdentity(item),
selected: selected.some((legacy) => matchesLegacyWorkspaceAttachment(item, legacy))
}))
}
}
export function selectReferenceWorkspace(
workspaces: readonly ReferenceWorkspace[],
selector: string,
cwd?: string
): ReferenceWorkspace {
let matches: ReferenceWorkspace[]
if (selector === 'current') {
if (!cwd) {
throw new Error('Current workspace requires a local working directory')
}
matches = workspaces.filter(
(row) => row.hostId === 'local' && isPathInsideOrEqual(row.path, cwd)
)
const longest = Math.max(...matches.map((row) => row.path.length))
matches = matches.filter((row) => row.path.length === longest)
} else if (selector.startsWith('identity:')) {
matches = workspaces.filter((row) => row.identity?.key === selector.slice(9))
} else if (selector.startsWith('id:')) {
matches = workspaces.filter((row) => worktreeIdsEqual(row.id, selector.slice(3)))
} else if (selector.startsWith('name:')) {
matches = workspaces.filter((row) => row.name === selector.slice(5))
} else if (selector.startsWith('path:')) {
const path = selector.slice(5)
const distro = cwd ? parseWslUncPath(cwd)?.distro : undefined
const linuxPath = path.startsWith('/') && !path.startsWith('//') && !path.includes('\\')
matches = workspaces.filter(
(row) =>
runtimePathsEqual(row.path, path) ||
(cwd &&
linuxPath &&
row.hostId === 'local' &&
(isWslUncPathForLinuxMountedPath(row.path, path) ||
(distro && isWslUncPathForCallerLinuxPath(row.path, path, distro))))
)
} else if (selector.startsWith('branch:')) {
matches = workspaces.filter(
(row) => row.branch && branchSelectorMatches(row.branch, selector.slice(7))
)
} else if (selector.startsWith('issue:')) {
matches = workspaces.filter(
(row) => typeof row.linkedIssue === 'number' && String(row.linkedIssue) === selector.slice(6)
)
} else {
matches = workspaces.filter(
(row) =>
row.id === selector ||
runtimePathsEqual(row.path, selector) ||
(row.branch && branchSelectorMatches(row.branch, selector))
)
}
// Why: duplicate registrations on one host describe one path; identical paths on different hosts do not.
if (
(selector === 'current' || selector.startsWith('path:')) &&
matches.length > 1 &&
new Set(matches.map((row) => row.hostId)).size === 1
) {
return matches[0]
}
if (matches.length !== 1) {
throw new Error(matches.length ? 'selector_ambiguous' : 'selector_not_found')
}
return matches[0]
}