mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Add host-qualified worktree identities
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Worktree Host Identity Rollout Validation
|
||||
|
||||
## Goal
|
||||
|
||||
Use host-qualified worktree IDs so the same repo/path on different execution hosts cannot collide:
|
||||
|
||||
```text
|
||||
orca-worktree://v1?hostId=<host>&repoId=<repo>&path=<absolute-path>
|
||||
```
|
||||
|
||||
Legacy IDs (`repoId::path`) remain accepted at public and persisted boundaries. Runtime and newly written state should resolve to canonical host-qualified IDs.
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
- New worktree IDs are canonical and include `hostId`, `repoId`, and `path`.
|
||||
- Legacy `repoId::path` IDs are parsed and accepted for existing sessions, metadata, cleanup requests, terminal selectors, browser scopes, and runtime CLI/mobile calls.
|
||||
- When Orca can prove a legacy ID maps to exactly one canonical repo/path/host, it returns and stores the canonical ID.
|
||||
- Existing legacy metadata and lineage are read through alias lookups before stamping new canonical metadata.
|
||||
- Old daemon PTY ids may remain legacy as PTY ids, but their owning `worktreeId` is canonicalized when the repo/path match is known.
|
||||
- Teardown sweeps both canonical and legacy PTY/session prefixes so old sessions are still killed during removal.
|
||||
|
||||
## Risks Addressed
|
||||
|
||||
- Local and SSH worktrees with the same repo/path no longer share one ID.
|
||||
- Legacy metadata remains visible during SSH reconnect/disconnected fallback.
|
||||
- Legacy terminal/session ids do not create duplicate runtime buckets after migration.
|
||||
- Cleanup preflight and skip-git deferrals accept both old and new ids.
|
||||
- Runtime lineage records seeded under legacy ids still attach to canonical worktree rows.
|
||||
|
||||
## Automated Evidence
|
||||
|
||||
Passing after fixes:
|
||||
|
||||
- `pnpm exec vitest run src/shared/worktree-id.test.ts src/main/daemon/pty-session-id.test.ts src/main/ipc/worktrees.test.ts`
|
||||
- `pnpm exec vitest run src/main/persistence.test.ts -t "migrateWorktreeIdentity|host-partitioned workspace sessions"`
|
||||
- `pnpm exec vitest run src/main/ipc/workspace-cleanup.test.ts src/main/ipc/remote-workspace.test.ts src/shared/remote-workspace-session-projection.test.ts src/main/memory/hydrate-local-pty-registry.test.ts`
|
||||
- `pnpm exec vitest run src/main/runtime/orca-runtime.test.ts -t "worktree|ManagedWorktree|listManagedWorktrees|createManagedWorktree"`
|
||||
- `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "migrateWorktreeIdentity"`
|
||||
- `pnpm run typecheck:node`
|
||||
- `pnpm run typecheck:web`
|
||||
- targeted `oxlint`
|
||||
- `git diff --check`
|
||||
|
||||
Node engine warning observed locally: repo wants Node 24; validation environment is Node v26.
|
||||
|
||||
## Electron Evidence
|
||||
|
||||
- Launched Electron with an isolated user data directory.
|
||||
- Verified the app identity matched this worktree before interacting.
|
||||
- Added a temp git repo and created an Orca-managed worktree through the app.
|
||||
- Confirmed the visible sidebar rendered the created workspace.
|
||||
- Confirmed store state used a canonical `orca-worktree://v1?...hostId=local...` id.
|
||||
- Removed the created workspace through a legacy `repoId::path` id and verified the canonical row disappeared.
|
||||
- Shut down only the Electron process launched for validation.
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeWorktreeKey } from '../../shared/worktree-id'
|
||||
import { isSafePtySessionId, mintPtySessionId, parsePtySessionId } from './pty-session-id'
|
||||
|
||||
const USER_DATA = '/tmp/orca-userdata'
|
||||
@@ -40,6 +41,15 @@ describe('isSafePtySessionId', () => {
|
||||
expect(isSafePtySessionId(id, USER_DATA)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts minted ids with host-qualified worktree keys', () => {
|
||||
const worktreeId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: 'repo-abc123',
|
||||
path: '/Users/thebr/work/wt-1'
|
||||
})
|
||||
expect(isSafePtySessionId(mintPtySessionId(worktreeId), USER_DATA)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts caller-supplied path-shaped ids that stay inside userData', () => {
|
||||
expect(isSafePtySessionId('some-repo::/Users/me/wt/abc@@deadbeef', USER_DATA)).toBe(true)
|
||||
})
|
||||
@@ -87,6 +97,15 @@ describe('parsePtySessionId', () => {
|
||||
expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt })
|
||||
})
|
||||
|
||||
it('round-trips a minted host-qualified worktree key back to its worktreeId', () => {
|
||||
const wt = makeWorktreeKey({
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-abc',
|
||||
path: '/srv/orca/worktree'
|
||||
})
|
||||
expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt })
|
||||
})
|
||||
|
||||
it('rejects bare UUIDs (no @@)', () => {
|
||||
expect(parsePtySessionId(mintPtySessionId())).toEqual({ worktreeId: null })
|
||||
})
|
||||
|
||||
@@ -18,7 +18,12 @@ import type {
|
||||
} from '../../shared/remote-workspace-types'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
getRepoIdFromWorktreeId,
|
||||
makeRepoWorktreeKey,
|
||||
splitWorktreeId
|
||||
} from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace'
|
||||
import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events'
|
||||
|
||||
@@ -224,6 +229,11 @@ function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
|
||||
}
|
||||
|
||||
function targetForWorktree(store: Store, worktreeId: string): string | null {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (parsed?.hostId) {
|
||||
const host = parseExecutionHostId(parsed.hostId)
|
||||
return host?.kind === 'ssh' ? host.targetId : null
|
||||
}
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return store.getRepo(repoId)?.connectionId ?? null
|
||||
}
|
||||
@@ -248,11 +258,11 @@ function importSessionForTarget(
|
||||
return importRemoteWorkspaceSession(remote, {
|
||||
resolveWorktreeId: (worktreePath) => {
|
||||
for (const repo of repoById.values()) {
|
||||
const candidate = `${repo.id}::${worktreePath}`
|
||||
const candidate = makeRepoWorktreeKey(repo, worktreePath)
|
||||
// Main does not own the live worktree list for SSH repos, so resolve
|
||||
// against repo identity only. Renderer hydration later validates IDs
|
||||
// against its fetched worktree list before panes mount.
|
||||
if (splitWorktreeId(candidate)) {
|
||||
if (splitWorktreeId(candidate)?.hostId === getRepoExecutionHostId(repo)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Repo,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { makeRepoWorktreeKey } from '../../shared/worktree-id'
|
||||
|
||||
const {
|
||||
listRepoWorktreesMock,
|
||||
@@ -453,7 +454,7 @@ describe('workspace cleanup scan', () => {
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates).toHaveLength(LARGE_WORKTREE_COUNT)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-1::/repo-feature-0',
|
||||
worktreeId: makeRepoWorktreeKey(REPO, '/repo-feature-0'),
|
||||
path: '/repo-feature-0',
|
||||
branch: 'feature-0',
|
||||
tier: 'review',
|
||||
@@ -463,7 +464,7 @@ describe('workspace cleanup scan', () => {
|
||||
}
|
||||
})
|
||||
expect(result.candidates[LARGE_WORKTREE_COUNT - 1]).toMatchObject({
|
||||
worktreeId: `repo-1::/repo-feature-${LARGE_WORKTREE_COUNT - 1}`,
|
||||
worktreeId: makeRepoWorktreeKey(REPO, `/repo-feature-${LARGE_WORKTREE_COUNT - 1}`),
|
||||
path: `/repo-feature-${LARGE_WORKTREE_COUNT - 1}`,
|
||||
branch: `feature-${LARGE_WORKTREE_COUNT - 1}`
|
||||
})
|
||||
|
||||
@@ -19,7 +19,14 @@ import type {
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
getRepoWorktreeIdAliases,
|
||||
isRepoWorktreeIdAlias,
|
||||
makeRepoWorktreeKey,
|
||||
parseWorktreeKey,
|
||||
splitWorktreeId
|
||||
} from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
applyWorkspaceCleanupPolicy,
|
||||
@@ -266,13 +273,17 @@ async function scanRepoWorkspaces(args: {
|
||||
|
||||
const worktrees = gitWorktrees
|
||||
.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const worktreeId = makeRepoWorktreeKey(repo, gitWorktree.path)
|
||||
const meta =
|
||||
store.getWorktreeMeta(worktreeId) ??
|
||||
store.getWorktreeMeta(`${repo.id}::${gitWorktree.path}`)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
})
|
||||
.filter((worktree) => {
|
||||
if (targetWorktreeId) {
|
||||
return worktree.id === targetWorktreeId
|
||||
return isRepoWorktreeIdAlias(repo, worktree.path, targetWorktreeId)
|
||||
}
|
||||
return (
|
||||
!repoIsFolder &&
|
||||
@@ -287,7 +298,9 @@ async function scanRepoWorkspaces(args: {
|
||||
worktree,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktree.id),
|
||||
skipGit: [...getRepoWorktreeIdAliases(repo, worktree.path)].some((id) =>
|
||||
skipGitWorktreeIds.has(id)
|
||||
),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
}).catch((error) => {
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
@@ -530,20 +543,38 @@ function synthesizeDisconnectedSshCandidates(
|
||||
targetWorktreeId?: string
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const repoWorktreePrefix = `${repo.id}::`
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
const isRepoWorktreeId = (worktreeId: string): boolean => {
|
||||
const parsed = parseWorktreeKey(worktreeId)
|
||||
if (parsed) {
|
||||
return parsed.repoId === repo.id && parsed.hostId === repoHostId
|
||||
}
|
||||
return worktreeId.startsWith(repoWorktreePrefix)
|
||||
}
|
||||
if (targetWorktreeId) {
|
||||
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
|
||||
if (!isRepoWorktreeId(targetWorktreeId)) {
|
||||
return []
|
||||
}
|
||||
// Why: focused delete preflight names one workspace already; walking all
|
||||
// persisted metadata is unnecessary for disconnected SSH repos.
|
||||
const meta = store.getWorktreeMeta(targetWorktreeId)
|
||||
const parsed = splitWorktreeId(targetWorktreeId)
|
||||
const canonicalTargetId =
|
||||
parsed && parsed.repoId === repo.id
|
||||
? makeRepoWorktreeKey(repo, parsed.worktreePath)
|
||||
: targetWorktreeId
|
||||
const legacyTargetId =
|
||||
parsed && parsed.repoId === repo.id ? `${repo.id}::${parsed.worktreePath}` : targetWorktreeId
|
||||
const meta =
|
||||
store.getWorktreeMeta(targetWorktreeId) ??
|
||||
store.getWorktreeMeta(canonicalTargetId) ??
|
||||
store.getWorktreeMeta(legacyTargetId)
|
||||
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
|
||||
}
|
||||
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
for (const worktreeId in allMeta) {
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !isRepoWorktreeId(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const meta = allMeta[worktreeId]
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { basename } from 'path'
|
||||
import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types'
|
||||
import {
|
||||
makeLegacyWorktreeId,
|
||||
makeWorktreeKey,
|
||||
type ParsedCanonicalWorktreeKey
|
||||
} from '../../shared/worktree-id'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata'
|
||||
|
||||
@@ -10,15 +15,19 @@ export function mergeWorktree(
|
||||
repoId: string,
|
||||
git: GitWorktreeInfo,
|
||||
meta: WorktreeMeta | undefined,
|
||||
defaultDisplayName?: string
|
||||
defaultDisplayName?: string,
|
||||
canonical?: Pick<ParsedCanonicalWorktreeKey, 'hostId'>
|
||||
): Worktree {
|
||||
const branchShort = git.branch.replace(/^refs\/heads\//, '')
|
||||
const hostId = meta?.hostId ?? canonical?.hostId
|
||||
return {
|
||||
id: `${repoId}::${git.path}`,
|
||||
id: hostId
|
||||
? makeWorktreeKey({ hostId, repoId, path: git.path })
|
||||
: makeLegacyWorktreeId(repoId, git.path),
|
||||
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId,
|
||||
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}),
|
||||
...(hostId !== undefined ? { hostId } : {}),
|
||||
...(meta?.projectHostSetupId !== undefined
|
||||
? { projectHostSetupId: meta.projectHostSetupId }
|
||||
: {}),
|
||||
|
||||
@@ -77,7 +77,8 @@ import {
|
||||
mergeWorktree,
|
||||
areWorktreePathsEqual
|
||||
} from './worktree-logic'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
|
||||
import { getRepoIdFromWorktreeId, makeRepoWorktreeKey } from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import {
|
||||
cleanupUnusedWorktreePushTargetRemoteWithExec,
|
||||
@@ -208,6 +209,13 @@ function recordWorkspaceLineageForCreatedWorktree(
|
||||
})
|
||||
}
|
||||
|
||||
function getRepoWorktreeId(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
path: string
|
||||
): string {
|
||||
return makeRepoWorktreeKey(repo, path)
|
||||
}
|
||||
|
||||
function countNonEmptyGitOutputLines(output: string): number {
|
||||
return output.split(/\r?\n/).filter((line) => line.trim().length > 0).length
|
||||
}
|
||||
@@ -1451,7 +1459,7 @@ export async function createRemoteWorktree(
|
||||
validateWorkspaceLineageParentBeforeCreate(
|
||||
store,
|
||||
args.parentWorkspace,
|
||||
worktreeWorkspaceKey(`${repo.id}::${remotePath}`)
|
||||
worktreeWorkspaceKey(getRepoWorktreeId(repo, remotePath))
|
||||
)
|
||||
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
@@ -1620,7 +1628,7 @@ export async function createRemoteWorktree(
|
||||
throw new Error('Worktree created but not found in listing')
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const worktreeId = getRepoWorktreeId(repo, created.path)
|
||||
const now = Date.now()
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
@@ -1694,7 +1702,11 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
return { worktree: mergeWorktree(repo.id, created, meta) }
|
||||
return {
|
||||
worktree: mergeWorktree(repo.id, created, meta, undefined, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
}
|
||||
})
|
||||
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
|
||||
|
||||
@@ -2059,7 +2071,7 @@ export async function createLocalWorktree(
|
||||
validateWorkspaceLineageParentBeforeCreate(
|
||||
store,
|
||||
args.parentWorkspace,
|
||||
worktreeWorkspaceKey(`${repo.id}::${worktreePath}`)
|
||||
worktreeWorkspaceKey(getRepoWorktreeId(repo, worktreePath))
|
||||
)
|
||||
|
||||
if (remoteTrackingRefresh) {
|
||||
@@ -2227,7 +2239,7 @@ export async function createLocalWorktree(
|
||||
throw new Error('Worktree created but not found in listing')
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const worktreeId = getRepoWorktreeId(repo, created.path)
|
||||
const now = Date.now()
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
@@ -2291,7 +2303,11 @@ export async function createLocalWorktree(
|
||||
}
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
return { worktree: mergeWorktree(repo.id, created, meta) }
|
||||
return {
|
||||
worktree: mergeWorktree(repo.id, created, meta, undefined, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
}
|
||||
})
|
||||
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
|
||||
// Why: creation already paid for `git worktree list`; seed the exact roots
|
||||
|
||||
+137
-45
@@ -4,9 +4,18 @@ import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types'
|
||||
import { makeRepoWorktreeKey } from '../../shared/worktree-id'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
||||
function localWorktreeKey(path: string, repoId = 'repo-1'): string {
|
||||
return makeRepoWorktreeKey({ id: repoId, connectionId: null, executionHostId: null }, path)
|
||||
}
|
||||
|
||||
function sshWorktreeKey(path: string, repoId = 'repo-ssh', connectionId = 'conn-1'): string {
|
||||
return makeRepoWorktreeKey({ id: repoId, connectionId, executionHostId: null }, path)
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -252,6 +261,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
getWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
migrateWorktreeIdentity: vi.fn(),
|
||||
getProjectHostSetups: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: vi.fn(),
|
||||
@@ -323,6 +333,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
store.getWorktreeMeta,
|
||||
store.getAllWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
store.migrateWorktreeIdentity,
|
||||
store.getProjectHostSetups,
|
||||
store.removeWorktreeMeta,
|
||||
store.getAllWorktreeLineage,
|
||||
@@ -703,7 +714,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
const persistedMeta = store.setWorktreeMeta.mock.calls.find(
|
||||
([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard'
|
||||
([worktreeId]) => worktreeId === localWorktreeKey('/workspace/improve-dashboard')
|
||||
)?.[1]
|
||||
expect(persistedMeta).toBeDefined()
|
||||
expect(persistedMeta).not.toHaveProperty('automationProvenance')
|
||||
@@ -782,7 +793,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
false
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::../worktrees/feature',
|
||||
localWorktreeKey('../worktrees/feature'),
|
||||
expect.objectContaining({
|
||||
orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false }
|
||||
})
|
||||
@@ -888,7 +899,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(result.worktree).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
|
||||
id: expect.stringMatching(
|
||||
/^orca-worktree:\/\/v1\?hostId=local&repoId=repo-folder&path=%2Fworkspace%2Ffolder::workspace:[0-9a-f-]{36}$/
|
||||
),
|
||||
repoId: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder-session',
|
||||
@@ -938,7 +951,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
`id:${localWorktreeKey('/workspace/improve-dashboard')}`,
|
||||
{
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
@@ -954,7 +967,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
`id:${localWorktreeKey('/workspace/improve-dashboard')}`,
|
||||
{
|
||||
title: 'Setup',
|
||||
command: 'bash /workspace/repo/.git/orca/setup-runner.sh',
|
||||
@@ -1023,7 +1036,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/fix-bug-0',
|
||||
localWorktreeKey('/workspace/fix-bug-0'),
|
||||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
@@ -1083,7 +1096,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/my-folder',
|
||||
localWorktreeKey('/workspace/my-folder'),
|
||||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
@@ -1213,7 +1226,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ cwd: '/workspace/fix-title' }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/fix-title',
|
||||
localWorktreeKey('/workspace/fix-title'),
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedPR: 42
|
||||
@@ -1252,7 +1265,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/fix-title',
|
||||
localWorktreeKey('/workspace/fix-title'),
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedGitLabMR: 7
|
||||
@@ -1302,7 +1315,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
false
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/bitbucket-title',
|
||||
localWorktreeKey('/workspace/bitbucket-title'),
|
||||
expect.objectContaining({ linkedBitbucketPR: 11 })
|
||||
)
|
||||
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
|
||||
@@ -1540,7 +1553,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
displayName: 'Fix: dashboards for PRs'
|
||||
})
|
||||
@@ -1574,7 +1587,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
@@ -1611,7 +1624,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
@@ -1667,7 +1680,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ cwd: '/workspace/improve-dashboard' }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
pushTarget: expect.objectContaining({
|
||||
remoteName: 'pr-prateek-orca',
|
||||
@@ -1724,7 +1737,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
pushTarget: expect.objectContaining({
|
||||
remoteName: 'pr-contributor-orca',
|
||||
@@ -2558,7 +2571,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-ssh::/remote/improve-dashboard',
|
||||
sshWorktreeKey('/remote/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
@@ -3134,7 +3147,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/remote/sparse-dashboard'
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-ssh::/remote/sparse-dashboard',
|
||||
sshWorktreeKey('/remote/sparse-dashboard'),
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
@@ -3931,9 +3944,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
resolveFetch()
|
||||
const result = (await createPromise) as CreateWorktreeResult
|
||||
expect(addWorktreeMock).toHaveBeenCalled()
|
||||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||||
expect(result.worktree.id).toBe(localWorktreeKey('/workspace/improve-dashboard'))
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({ baseRef: 'refs/remotes/origin/main' })
|
||||
)
|
||||
})
|
||||
@@ -3995,7 +4008,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
|
||||
worktree: expect.objectContaining({ id: localWorktreeKey('/workspace/improve-dashboard') })
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -4050,7 +4063,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({ baseRef: 'refs/remotes/origin/main' })
|
||||
)
|
||||
expect(result.localBaseRefUpdateSuggestion).toEqual({
|
||||
@@ -4108,7 +4121,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
it('lists a synthetic worktree for folder-mode repos', async () => {
|
||||
const rootWorktreeId = 'repo-1::/workspace/folder'
|
||||
const rootWorktreeId = localWorktreeKey('/workspace/folder')
|
||||
const priorWorktreeIds = ['repo-1::/workspace/old-folder']
|
||||
const rootMeta = makeWorktreeMeta({
|
||||
instanceId: 'folder-instance',
|
||||
@@ -4203,7 +4216,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
id: sshWorktreeKey('/remote/feature-wt'),
|
||||
repoId: 'repo-ssh',
|
||||
path: '/remote/feature-wt',
|
||||
head: '',
|
||||
@@ -4233,8 +4246,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(store.getWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', {
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(sshWorktreeKey('/remote/feature-wt'), {
|
||||
projectId: 'repo:repo-ssh',
|
||||
hostId: 'ssh:conn-1',
|
||||
projectHostSetupId: 'repo-ssh'
|
||||
@@ -4267,7 +4279,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
id: sshWorktreeKey('/remote/feature-wt'),
|
||||
displayName: 'Feature workspace',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
@@ -4307,7 +4319,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
id: sshWorktreeKey('/remote/feature-wt'),
|
||||
displayName: 'Good row'
|
||||
})
|
||||
])
|
||||
@@ -4399,11 +4411,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(listed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
id: sshWorktreeKey('/remote/feature-wt'),
|
||||
displayName: 'Remote cached'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'repo-local::/workspace/local',
|
||||
id: localWorktreeKey('/workspace/local', 'repo-local'),
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
])
|
||||
@@ -4437,8 +4449,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }),
|
||||
expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' })
|
||||
expect.objectContaining({ id: sshWorktreeKey('/remote/a/one', 'repo-ssh-a', 'conn-1') }),
|
||||
expect.objectContaining({ id: sshWorktreeKey('/remote/b/two', 'repo-ssh-b', 'conn-2') })
|
||||
])
|
||||
})
|
||||
|
||||
@@ -4464,6 +4476,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
}
|
||||
store.setWorktreeMeta.mockReturnValue(stampedMeta)
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/discovered-wt'
|
||||
)
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
@@ -4471,7 +4487,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expectedWorktreeId,
|
||||
expect.objectContaining({
|
||||
lastActivityAt: expect.any(Number),
|
||||
projectId: 'repo:repo-1',
|
||||
@@ -4480,11 +4496,63 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
id: expectedWorktreeId,
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates matching legacy worktree metadata to the host-qualified key on discovery', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/legacy-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const legacyWorktreeId = 'repo-1::/workspace/legacy-wt'
|
||||
const canonicalWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/legacy-wt'
|
||||
)
|
||||
const legacyMeta = makeWorktreeMeta({
|
||||
instanceId: 'legacy-instance',
|
||||
hostId: 'local',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => {
|
||||
if (worktreeId === legacyWorktreeId) {
|
||||
return legacyMeta
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
...legacyMeta,
|
||||
projectId: 'repo:repo-1',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.migrateWorktreeIdentity).toHaveBeenCalledWith(
|
||||
legacyWorktreeId,
|
||||
canonicalWorktreeId
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
canonicalWorktreeId,
|
||||
expect.objectContaining({
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
)
|
||||
expect(listed[0]).toMatchObject({ id: canonicalWorktreeId, lastActivityAt: 42 })
|
||||
})
|
||||
|
||||
it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => {
|
||||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||||
// would overwrite real activity and reshuffle the sidebar on refresh. Host
|
||||
@@ -4525,8 +4593,12 @@ describe('registerWorktreeHandlers', () => {
|
||||
hostId?: string
|
||||
projectHostSetupId?: string
|
||||
}[]
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/existing-wt'
|
||||
)
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, {
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
@@ -4596,12 +4668,16 @@ describe('registerWorktreeHandlers', () => {
|
||||
hostId?: string
|
||||
projectHostSetupId?: string
|
||||
}[]
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/existing-wt'
|
||||
)
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, {
|
||||
projectId: 'github:stablyai/orca'
|
||||
})
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/existing-wt',
|
||||
id: expectedWorktreeId,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
@@ -4704,14 +4780,18 @@ describe('registerWorktreeHandlers', () => {
|
||||
projectHostSetupId?: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-ssh', connectionId: 'ssh-target-1', executionHostId: null },
|
||||
'/remote/orca'
|
||||
)
|
||||
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1')
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', {
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, {
|
||||
projectId: 'github:stablyai/orca'
|
||||
})
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/orca',
|
||||
id: expectedWorktreeId,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
projectHostSetupId: 'repo-ssh',
|
||||
@@ -4784,9 +4864,13 @@ describe('registerWorktreeHandlers', () => {
|
||||
id: string
|
||||
instanceId?: string
|
||||
}[]
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/existing-wt'
|
||||
)
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/existing-wt',
|
||||
expectedWorktreeId,
|
||||
expect.objectContaining({
|
||||
instanceId: expect.any(String),
|
||||
projectId: 'repo:repo-1',
|
||||
@@ -4831,11 +4915,15 @@ describe('registerWorktreeHandlers', () => {
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/folder'
|
||||
)
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/folder',
|
||||
expectedWorktreeId,
|
||||
expect.objectContaining({
|
||||
lastActivityAt: expect.any(Number),
|
||||
projectId: 'repo:repo-1',
|
||||
@@ -4860,6 +4948,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
const expectedWorktreeId = makeRepoWorktreeKey(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
'/workspace/discovered-wt'
|
||||
)
|
||||
|
||||
const listed = (await handlers['worktrees:listAll'](null, undefined)) as {
|
||||
id: string
|
||||
@@ -4867,11 +4959,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expectedWorktreeId,
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
id: expectedWorktreeId,
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
@@ -5212,7 +5304,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
false
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web', 'apps/api'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
@@ -5258,7 +5350,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
@@ -5295,7 +5387,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
localWorktreeKey('/workspace/improve-dashboard'),
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
|
||||
+122
-16
@@ -114,7 +114,13 @@ import {
|
||||
} from '../worktree-removal-safety'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
makeLegacyWorktreeId,
|
||||
makeRepoWorktreeKey,
|
||||
parseWorktreeKey
|
||||
} from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
|
||||
import {
|
||||
getLocalProjectGitExecOptions,
|
||||
@@ -176,6 +182,39 @@ function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeI
|
||||
return uniqueGitWorktrees
|
||||
}
|
||||
|
||||
function getRepoWorktreeId(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
path: string
|
||||
): string {
|
||||
return makeRepoWorktreeKey(repo, path)
|
||||
}
|
||||
|
||||
function getRepoLegacyWorktreeId(repo: Pick<Repo, 'id'>, path: string): string {
|
||||
return makeLegacyWorktreeId(repo.id, path)
|
||||
}
|
||||
|
||||
function migrateLegacyWorktreeMetaIfSafe(store: Store, repo: Repo, path: string): void {
|
||||
const canonicalId = getRepoWorktreeId(repo, path)
|
||||
if (store.getWorktreeMeta(canonicalId)) {
|
||||
return
|
||||
}
|
||||
const legacyId = getRepoLegacyWorktreeId(repo, path)
|
||||
const legacyMeta = store.getWorktreeMeta(legacyId)
|
||||
if (!legacyMeta) {
|
||||
return
|
||||
}
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
if (legacyMeta.hostId !== undefined && legacyMeta.hostId !== repoHostId) {
|
||||
return
|
||||
}
|
||||
store.migrateWorktreeIdentity(legacyId, canonicalId)
|
||||
}
|
||||
|
||||
function isCanonicalWorktreeIdForRepoHost(repo: Repo, worktreeId: string): boolean {
|
||||
const parsed = parseWorktreeKey(worktreeId)
|
||||
return parsed?.repoId === repo.id && parsed.hostId === getRepoExecutionHostId(repo)
|
||||
}
|
||||
|
||||
function getProjectHostSetupMetaUpdates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
@@ -506,14 +545,17 @@ function pruneLineageForMissingRepoWorktrees(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`))
|
||||
const liveIds = new Set(gitWorktrees.map((worktree) => getRepoWorktreeId(repo, worktree.path)))
|
||||
const legacyLiveIds = new Set(
|
||||
gitWorktrees.map((worktree) => getRepoLegacyWorktreeId(repo, worktree.path))
|
||||
)
|
||||
const repoPrefix = `${repo.id}::`
|
||||
for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) {
|
||||
const childScope = parseWorkspaceKey(childWorkspaceKey)
|
||||
if (
|
||||
childScope?.type === 'worktree' &&
|
||||
childScope.worktreeId.startsWith(repoPrefix) &&
|
||||
!liveIds.has(childScope.worktreeId)
|
||||
!legacyLiveIds.has(childScope.worktreeId)
|
||||
) {
|
||||
if (isWorkspaceKey(childWorkspaceKey)) {
|
||||
store.removeWorkspaceLineage?.(childWorkspaceKey)
|
||||
@@ -521,7 +563,10 @@ function pruneLineageForMissingRepoWorktrees(
|
||||
}
|
||||
}
|
||||
for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
|
||||
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
|
||||
if (
|
||||
(childId.startsWith(repoPrefix) && !legacyLiveIds.has(childId)) ||
|
||||
(isCanonicalWorktreeIdForRepoHost(repo, childId) && !liveIds.has(childId))
|
||||
) {
|
||||
// Why: path-derived IDs can disappear and later be reused by a different
|
||||
// checkout. Once a successful scan proves the child is gone, drop its
|
||||
// lineage so a future same-path worktree cannot inherit it. Missing
|
||||
@@ -530,7 +575,10 @@ function pruneLineageForMissingRepoWorktrees(
|
||||
store.removeWorktreeLineage(childId)
|
||||
store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId))
|
||||
}
|
||||
if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) {
|
||||
if (
|
||||
lineage.parentWorktreeId.startsWith(repoPrefix) &&
|
||||
!legacyLiveIds.has(lineage.parentWorktreeId)
|
||||
) {
|
||||
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
|
||||
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
|
||||
// Why: keep the child lineage so the UI can show "Missing parent", but
|
||||
@@ -595,13 +643,16 @@ function listDisconnectedSshWorktrees(
|
||||
): ReturnType<typeof mergeWorktree>[] {
|
||||
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
|
||||
for (const candidate of metaIndex.get(repo.id) ?? []) {
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta)
|
||||
migrateLegacyWorktreeMetaIfSafe(store, repo, candidate.path)
|
||||
const canonicalId = getRepoWorktreeId(repo, candidate.path)
|
||||
const candidateMeta = store.getWorktreeMeta(canonicalId) ?? candidate.meta
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidateMeta)
|
||||
const meta =
|
||||
Object.keys(ownershipUpdates).length > 0
|
||||
? { ...candidate.meta, ...ownershipUpdates }
|
||||
: candidate.meta
|
||||
? { ...candidateMeta, ...ownershipUpdates }
|
||||
: candidateMeta
|
||||
if (Object.keys(ownershipUpdates).length > 0) {
|
||||
store.setWorktreeMeta(candidate.id, ownershipUpdates)
|
||||
store.setWorktreeMeta(canonicalId, ownershipUpdates)
|
||||
}
|
||||
const worktree = mergeWorktree(
|
||||
repo.id,
|
||||
@@ -623,9 +674,12 @@ function buildDetectedGitWorktrees(
|
||||
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
return dedupeGitWorktreesByPath(gitWorktrees).map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
migrateLegacyWorktreeMetaIfSafe(store, repo, gitWorktree.path)
|
||||
const worktreeId = getRepoWorktreeId(repo, gitWorktree.path)
|
||||
let meta = store.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
worktree,
|
||||
@@ -641,7 +695,9 @@ function buildDetectedGitWorktrees(
|
||||
meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId)
|
||||
return toDetectedWorktree({
|
||||
repo,
|
||||
worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
}),
|
||||
meta,
|
||||
settings,
|
||||
knownOrcaLayouts,
|
||||
@@ -655,21 +711,32 @@ function stampAndMergeVisibleDetectedWorktree(
|
||||
repo: Repo,
|
||||
detected: DetectedWorktree
|
||||
) {
|
||||
migrateLegacyWorktreeMetaIfSafe(store, repo, detected.path)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id)
|
||||
return mergeWorktree(repo.id, detected, meta, repo.displayName)
|
||||
return mergeWorktree(repo.id, detected, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
}
|
||||
|
||||
function getFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
return getRepoWorktreeId(repo, repo.path)
|
||||
}
|
||||
|
||||
function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
|
||||
function getLegacyFolderWorkspaceRootId(repo: Repo): string {
|
||||
return getRepoLegacyWorktreeId(repo, repo.path)
|
||||
}
|
||||
|
||||
function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
|
||||
const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
|
||||
const legacyPrefix = `${getLegacyFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
if (worktreeId.startsWith(prefix)) {
|
||||
return worktreeId.slice(prefix.length)
|
||||
}
|
||||
return worktreeId.startsWith(legacyPrefix) ? worktreeId.slice(legacyPrefix.length) : randomUUID()
|
||||
}
|
||||
|
||||
function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
@@ -680,6 +747,44 @@ function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function isLegacyFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
const rootId = getLegacyFolderWorkspaceRootId(repo)
|
||||
return (
|
||||
worktreeId === rootId ||
|
||||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
|
||||
)
|
||||
}
|
||||
|
||||
function isFolderWorkspaceRootIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
return (
|
||||
worktreeId === getFolderWorkspaceRootId(repo) ||
|
||||
worktreeId === getLegacyFolderWorkspaceRootId(repo)
|
||||
)
|
||||
}
|
||||
|
||||
function getCanonicalFolderWorkspaceId(repo: Repo, worktreeId: string): string {
|
||||
if (!isLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) {
|
||||
return worktreeId
|
||||
}
|
||||
const instanceId = getFolderWorkspaceInstanceIdentity(repo, worktreeId)
|
||||
return worktreeId === getLegacyFolderWorkspaceRootId(repo)
|
||||
? getFolderWorkspaceRootId(repo)
|
||||
: getFolderWorkspaceInstanceId(repo, instanceId)
|
||||
}
|
||||
|
||||
function migrateLegacyFolderWorkspaceMetaIfSafe(store: Store, repo: Repo): void {
|
||||
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
|
||||
if (!isLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
if (meta.hostId !== undefined && meta.hostId !== repoHostId) {
|
||||
continue
|
||||
}
|
||||
store.migrateWorktreeIdentity(worktreeId, getCanonicalFolderWorkspaceId(repo, worktreeId))
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
@@ -726,6 +831,7 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta
|
||||
}
|
||||
|
||||
function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
migrateLegacyFolderWorkspaceMetaIfSafe(store, repo)
|
||||
const rootId = getFolderWorkspaceRootId(repo)
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const ids = Object.keys(allMeta).filter((worktreeId) =>
|
||||
@@ -1296,7 +1402,7 @@ export function registerWorktreeHandlers(
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
if (isFolderWorkspaceRootIdForRepo(repo, args.worktreeId)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { makeRepoWorktreeKey } from '../../shared/worktree-id'
|
||||
import type { SessionInfo } from '../daemon/types'
|
||||
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -220,7 +221,16 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
|
||||
const entry = listRegisteredPtys().find((p) => p.ptyId === ptyId)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.pid).toBe(4242)
|
||||
expect(entry!.worktreeId).toBe('repo-a::/local/Triton')
|
||||
expect(entry!.worktreeId).toBe(
|
||||
makeRepoWorktreeKey(
|
||||
{
|
||||
id: 'repo-a',
|
||||
connectionId: null,
|
||||
executionHostId: null
|
||||
},
|
||||
'/local/Triton'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('hydrates large daemon session lists', async () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { SessionInfo } from '../daemon/types'
|
||||
import { listRegisteredPtys, registerPty } from './pty-registry'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { parsePtySessionId } from '../../shared/pty-session-id-format'
|
||||
import { getRepoWorktreeIdAliases, makeRepoWorktreeKey } from '../../shared/worktree-id'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
// Why: `attachMainWindowServices` runs on every macOS dock re-activation
|
||||
@@ -69,11 +70,10 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
|
||||
// renderer-side union still covers that case.
|
||||
hasHydrated = true
|
||||
|
||||
// Why: build a worktree-id → connectionId map so we can SSH-gate each
|
||||
// session before registering. Live git enumeration matches the path
|
||||
// shape used by `mintPtySessionId` (`${repoId}::${path}`).
|
||||
// Why: build a worktree-id → canonical worktree-id map so old daemon
|
||||
// sessions (`repoId::path`) hydrate into the new host-qualified bucket.
|
||||
const repos = store.getRepos()
|
||||
const repoConnectionIdByWorktreeId = new Map<string, string | null>()
|
||||
const canonicalWorktreeIdByAlias = new Map<string, string>()
|
||||
|
||||
for (const repo of repos) {
|
||||
const connectionId = repo.connectionId ?? null
|
||||
@@ -84,8 +84,10 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
|
||||
}
|
||||
const worktrees = await listRepoWorktrees(repo)
|
||||
for (const wt of worktrees) {
|
||||
const worktreeId = `${repo.id}::${wt.path}`
|
||||
repoConnectionIdByWorktreeId.set(worktreeId, connectionId)
|
||||
const canonicalWorktreeId = makeRepoWorktreeKey(repo, wt.path)
|
||||
for (const alias of getRepoWorktreeIdAliases(repo, wt.path)) {
|
||||
canonicalWorktreeIdByAlias.set(alias, canonicalWorktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,15 +116,13 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
|
||||
// `src/main/ipc/pty.ts`. If the repo isn't in the store, skip the
|
||||
// session: we can't prove it's local, and the renderer-side union
|
||||
// still surfaces the session at the cost of a missing pid sample.
|
||||
if (!repoConnectionIdByWorktreeId.has(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
if (repoConnectionIdByWorktreeId.get(worktreeId)) {
|
||||
const canonicalWorktreeId = canonicalWorktreeIdByAlias.get(worktreeId)
|
||||
if (!canonicalWorktreeId) {
|
||||
continue
|
||||
}
|
||||
registerPty({
|
||||
ptyId: info.sessionId,
|
||||
worktreeId,
|
||||
worktreeId: canonicalWorktreeId,
|
||||
sessionId: info.sessionId,
|
||||
paneKey: null,
|
||||
pid:
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
} from '../../shared/constants'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, makeWorktreeKey } from '../../shared/worktree-id'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest } from './rpc/core'
|
||||
import { TERMINAL_METHODS } from './rpc/methods/terminal'
|
||||
@@ -713,6 +713,11 @@ const TEST_REPO_ID = 'repo-1'
|
||||
const TEST_REPO_PATH = '/tmp/repo'
|
||||
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
|
||||
const TEST_CANONICAL_WORKTREE_ID = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: TEST_WORKTREE_PATH
|
||||
})
|
||||
const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1'
|
||||
const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1'
|
||||
const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}`
|
||||
@@ -1419,7 +1424,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(terminals.terminals).toHaveLength(1)
|
||||
expect(terminals.terminals[0]).toMatchObject({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
worktreeId: TEST_CANONICAL_WORKTREE_ID,
|
||||
worktreePath: TEST_WORKTREE_PATH
|
||||
})
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
@@ -1450,8 +1455,8 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_ID
|
||||
TEST_CANONICAL_WORKTREE_ID,
|
||||
TEST_CANONICAL_WORKTREE_ID
|
||||
])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has(ptyId)).toBe(true)
|
||||
@@ -1487,7 +1492,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
|
||||
TEST_CANONICAL_WORKTREE_ID
|
||||
])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
|
||||
@@ -1524,7 +1531,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
|
||||
TEST_CANONICAL_WORKTREE_ID
|
||||
])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
|
||||
@@ -1562,7 +1571,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
|
||||
TEST_CANONICAL_WORKTREE_ID
|
||||
])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('target-cwd-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('sibling-cwd-pty')).toBe(false)
|
||||
@@ -1784,6 +1795,16 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
const mainId = `${remoteRepo.id}::/home/user/repo`
|
||||
const childId = `${remoteRepo.id}::/home/user/repo-child`
|
||||
const canonicalMainId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-missing',
|
||||
repoId: remoteRepo.id,
|
||||
path: '/home/user/repo'
|
||||
})
|
||||
const canonicalChildId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-missing',
|
||||
repoId: remoteRepo.id,
|
||||
path: '/home/user/repo-child'
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[mainId]: makeWorktreeMeta({ displayName: 'Remote main' }),
|
||||
[childId]: makeWorktreeMeta({ displayName: 'Remote child', linkedPR: 42 })
|
||||
@@ -1810,14 +1831,14 @@ describe('OrcaRuntimeService', () => {
|
||||
truncated: false,
|
||||
worktrees: [
|
||||
{
|
||||
id: mainId,
|
||||
id: canonicalMainId,
|
||||
path: '/home/user/repo',
|
||||
branch: '',
|
||||
isMainWorktree: true,
|
||||
displayName: 'Remote main'
|
||||
},
|
||||
{
|
||||
id: childId,
|
||||
id: canonicalChildId,
|
||||
path: '/home/user/repo-child',
|
||||
branch: '',
|
||||
isMainWorktree: false,
|
||||
@@ -1879,7 +1900,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
staleScan.resolve(MOCK_GIT_WORKTREES)
|
||||
|
||||
await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID })
|
||||
await expect(staleLookup).resolves.toMatchObject({ id: TEST_CANONICAL_WORKTREE_ID })
|
||||
await expect(freshLookup).resolves.toMatchObject({
|
||||
id: result.worktree.id,
|
||||
path: createdWorktree.path
|
||||
@@ -2790,8 +2811,13 @@ describe('OrcaRuntimeService', () => {
|
||||
'/remote/mobile-feature',
|
||||
{ base: 'origin/main' }
|
||||
)
|
||||
const canonicalCreatedId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: created.path
|
||||
})
|
||||
expect(result.worktree).toMatchObject({
|
||||
id: `${TEST_REPO_ID}::${created.path}`,
|
||||
id: canonicalCreatedId,
|
||||
path: created.path,
|
||||
linkedGitLabIssue: 321,
|
||||
linkedGitLabMR: 654
|
||||
@@ -2830,7 +2856,16 @@ describe('OrcaRuntimeService', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
const parentId = `${TEST_REPO_ID}::${parent.path}`
|
||||
const childId = `${TEST_REPO_ID}::${created.path}`
|
||||
const canonicalParentId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: parent.path
|
||||
})
|
||||
const canonicalChildId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: created.path
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' })
|
||||
}
|
||||
@@ -2882,19 +2917,22 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
expect(result.worktree).toMatchObject({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
id: canonicalChildId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
lineage: expect.objectContaining({
|
||||
worktreeId: childId,
|
||||
parentWorktreeId: parentId,
|
||||
worktreeInstanceId: metaById[childId].instanceId,
|
||||
worktreeId: canonicalChildId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
worktreeInstanceId: metaById[canonicalChildId].instanceId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli'
|
||||
})
|
||||
})
|
||||
expect(result.lineage).toBe(result.worktree.lineage)
|
||||
expect(result.warnings).toEqual([])
|
||||
expect(remoteStore.setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object))
|
||||
expect(remoteStore.setWorktreeLineage).toHaveBeenCalledWith(
|
||||
canonicalChildId,
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(addWorktree).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
@@ -3491,7 +3529,13 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/feature', true)
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`)
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(
|
||||
makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: '/remote/feature'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects SSH-backed runtime removal of the main worktree before provider deletion', async () => {
|
||||
@@ -14705,7 +14749,7 @@ describe('OrcaRuntimeService', () => {
|
||||
worktrees: [
|
||||
{
|
||||
workspaceKind: 'git',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
worktreeId: TEST_CANONICAL_WORKTREE_ID,
|
||||
repoId: 'repo-1',
|
||||
repo: 'repo',
|
||||
path: '/tmp/worktree-a',
|
||||
@@ -14758,7 +14802,7 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
const summary = worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID)
|
||||
const summary = worktrees.find((w) => w.worktreeId === TEST_CANONICAL_WORKTREE_ID)
|
||||
expect(summary?.linkedPR).toEqual({ number: 42, state: 'merged' })
|
||||
})
|
||||
|
||||
@@ -14993,7 +15037,7 @@ describe('OrcaRuntimeService', () => {
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
const active = worktrees.filter((w) => w.isActive)
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0]?.worktreeId).toBe(TEST_WORKTREE_ID)
|
||||
expect(active[0]?.worktreeId).toBe(TEST_CANONICAL_WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('includes SSH-backed worktrees in the mobile worktree summary', async () => {
|
||||
@@ -15012,6 +15056,11 @@ describe('OrcaRuntimeService', () => {
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const canonicalRemoteWorktreeId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: remoteRepo.id,
|
||||
path: remoteWorktree.path
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[`${remoteRepo.id}::${remoteWorktree.path}`]: makeWorktreeMeta({
|
||||
displayName: 'Remote mobile'
|
||||
@@ -15037,7 +15086,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(summaries.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: `${remoteRepo.id}::${remoteWorktree.path}`,
|
||||
worktreeId: canonicalRemoteWorktreeId,
|
||||
repoId: remoteRepo.id,
|
||||
repo: 'remote-vm',
|
||||
path: remoteWorktree.path,
|
||||
@@ -15824,6 +15873,16 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
const childId = `${remoteRepo.id}::/home/user/repo-child`
|
||||
const parentId = `${remoteRepo.id}::/home/user/repo-parent`
|
||||
const canonicalChildId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: remoteRepo.id,
|
||||
path: '/home/user/repo-child'
|
||||
})
|
||||
const canonicalParentId = makeWorktreeKey({
|
||||
hostId: 'ssh:ssh-1',
|
||||
repoId: remoteRepo.id,
|
||||
path: '/home/user/repo-parent'
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' }),
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' })
|
||||
@@ -15867,11 +15926,11 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path)
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
canonicalChildId,
|
||||
expect.objectContaining({
|
||||
worktreeId: childId,
|
||||
worktreeId: canonicalChildId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual'
|
||||
})
|
||||
@@ -16128,6 +16187,16 @@ describe('OrcaRuntimeService', () => {
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const canonicalParentId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: parentPath
|
||||
})
|
||||
const canonicalChildId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: childPath
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
@@ -16169,18 +16238,18 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
canonicalChildId,
|
||||
expect.objectContaining({
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' }
|
||||
})
|
||||
)
|
||||
expect(setWorkspaceLineage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childWorkspaceKey: `worktree:${childId}`,
|
||||
childWorkspaceKey: `worktree:${canonicalChildId}`,
|
||||
childInstanceId: 'child-instance',
|
||||
parentWorkspaceKey: `worktree:${parentId}`,
|
||||
parentWorkspaceKey: `worktree:${canonicalParentId}`,
|
||||
parentInstanceId: 'parent-instance',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' }
|
||||
})
|
||||
@@ -16574,6 +16643,16 @@ describe('OrcaRuntimeService', () => {
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const canonicalParentId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: parentPath
|
||||
})
|
||||
const canonicalChildId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: childPath
|
||||
})
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
@@ -16624,24 +16703,32 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const listed = await runtime.listManagedWorktrees('id:repo-1')
|
||||
const parent = listed.worktrees.find((worktree) => worktree.id === parentId)
|
||||
const child = listed.worktrees.find((worktree) => worktree.id === childId)
|
||||
const parent = listed.worktrees.find((worktree) => worktree.id === canonicalParentId)
|
||||
const child = listed.worktrees.find((worktree) => worktree.id === canonicalChildId)
|
||||
|
||||
expect(parent).toMatchObject({
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
childWorktreeIds: [canonicalChildId],
|
||||
lineage: null
|
||||
})
|
||||
expect(child).toMatchObject({
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
childWorktreeIds: [],
|
||||
lineage: lineageById[childId]
|
||||
lineage: {
|
||||
...lineageById[childId],
|
||||
worktreeId: canonicalChildId,
|
||||
parentWorktreeId: canonicalParentId
|
||||
}
|
||||
})
|
||||
await expect(runtime.showManagedWorktree(`id:${childId}`)).resolves.toMatchObject({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
id: canonicalChildId,
|
||||
parentWorktreeId: canonicalParentId,
|
||||
childWorktreeIds: [],
|
||||
lineage: lineageById[childId]
|
||||
lineage: {
|
||||
...lineageById[childId],
|
||||
worktreeId: canonicalChildId,
|
||||
parentWorktreeId: canonicalParentId
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21242,7 +21329,7 @@ describe('OrcaRuntimeService', () => {
|
||||
page: 'page-1'
|
||||
})
|
||||
|
||||
expect(snapshotMock).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'page-1')
|
||||
expect(snapshotMock).toHaveBeenCalledWith(TEST_CANONICAL_WORKTREE_ID, 'page-1')
|
||||
})
|
||||
|
||||
it('routes tab switch and capture start by explicit page id', async () => {
|
||||
@@ -21361,8 +21448,13 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
const worktreeBId = makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: TEST_REPO_ID,
|
||||
path: '/tmp/worktree-b'
|
||||
})
|
||||
const getRegisteredTabsMock = vi.fn((worktreeId?: string) =>
|
||||
worktreeId === `${TEST_REPO_ID}::/tmp/worktree-b` ? new Map() : new Map([['page-1', 1]])
|
||||
worktreeId === worktreeBId ? new Map() : new Map([['page-1', 1]])
|
||||
)
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
@@ -21375,7 +21467,7 @@ describe('OrcaRuntimeService', () => {
|
||||
worktree: 'path:/tmp/worktree-b'
|
||||
})
|
||||
).rejects.toThrow('Browser page page-1 was not found in this worktree')
|
||||
expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`)
|
||||
expect(getRegisteredTabsMock).toHaveBeenCalledWith(worktreeBId)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -143,9 +143,13 @@ import type { FeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
makeLegacyWorktreeId,
|
||||
makeRepoWorktreeKey,
|
||||
parseWorktreeKey,
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem
|
||||
} from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
@@ -715,6 +719,7 @@ type RuntimeStore = {
|
||||
getWorktreeMeta: Store['getWorktreeMeta']
|
||||
setWorktreeMeta: Store['setWorktreeMeta']
|
||||
removeWorktreeMeta: Store['removeWorktreeMeta']
|
||||
migrateWorktreeIdentity?: Store['migrateWorktreeIdentity']
|
||||
getWorktreeLineage?: Store['getWorktreeLineage']
|
||||
getAllWorktreeLineage?: Store['getAllWorktreeLineage']
|
||||
setWorktreeLineage?: Store['setWorktreeLineage']
|
||||
@@ -1217,16 +1222,24 @@ function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean):
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
return makeRepoWorktreeKey(repo, repo.path)
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
|
||||
function getRuntimeLegacyFolderWorkspaceRootId(repo: Repo): string {
|
||||
return makeLegacyWorktreeId(repo.id, repo.path)
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
|
||||
const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
|
||||
const legacyPrefix = `${getRuntimeLegacyFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
if (worktreeId.startsWith(prefix)) {
|
||||
return worktreeId.slice(prefix.length)
|
||||
}
|
||||
return worktreeId.startsWith(legacyPrefix) ? worktreeId.slice(legacyPrefix.length) : randomUUID()
|
||||
}
|
||||
|
||||
function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
@@ -1237,6 +1250,100 @@ function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): bool
|
||||
)
|
||||
}
|
||||
|
||||
function isRuntimeLegacyFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
const rootId = getRuntimeLegacyFolderWorkspaceRootId(repo)
|
||||
return (
|
||||
worktreeId === rootId ||
|
||||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
|
||||
)
|
||||
}
|
||||
|
||||
function getRuntimeCanonicalFolderWorkspaceId(repo: Repo, worktreeId: string): string {
|
||||
if (!isRuntimeLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) {
|
||||
return worktreeId
|
||||
}
|
||||
const instanceId = getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId)
|
||||
return worktreeId === getRuntimeLegacyFolderWorkspaceRootId(repo)
|
||||
? getRuntimeFolderWorkspaceRootId(repo)
|
||||
: getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
|
||||
}
|
||||
|
||||
function getRuntimeRepoWorktreeId(repo: Repo, path: string): string {
|
||||
return makeRepoWorktreeKey(repo, path)
|
||||
}
|
||||
|
||||
function getRuntimeLegacyRepoWorktreeId(repo: Repo, path: string): string {
|
||||
return makeLegacyWorktreeId(repo.id, path)
|
||||
}
|
||||
|
||||
function getRuntimeWorktreeMetaForRepoPath(
|
||||
store: RuntimeStore,
|
||||
repo: Repo,
|
||||
path: string
|
||||
): WorktreeMeta | undefined {
|
||||
migrateRuntimeLegacyWorktreeMetaIfSafe(store, repo, path)
|
||||
return (
|
||||
store.getWorktreeMeta(getRuntimeRepoWorktreeId(repo, path)) ??
|
||||
store.getWorktreeMeta(getRuntimeLegacyRepoWorktreeId(repo, path))
|
||||
)
|
||||
}
|
||||
|
||||
function migrateRuntimeLegacyWorktreeMetaIfSafe(
|
||||
store: RuntimeStore,
|
||||
repo: Repo,
|
||||
path: string
|
||||
): void {
|
||||
if (!store.migrateWorktreeIdentity) {
|
||||
return
|
||||
}
|
||||
const canonicalId = getRuntimeRepoWorktreeId(repo, path)
|
||||
if (store.getWorktreeMeta(canonicalId)) {
|
||||
return
|
||||
}
|
||||
const legacyId = getRuntimeLegacyRepoWorktreeId(repo, path)
|
||||
const legacyMeta = store.getWorktreeMeta(legacyId)
|
||||
if (!legacyMeta) {
|
||||
return
|
||||
}
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
if (legacyMeta.hostId !== undefined && legacyMeta.hostId !== repoHostId) {
|
||||
return
|
||||
}
|
||||
store.migrateWorktreeIdentity(legacyId, canonicalId)
|
||||
}
|
||||
|
||||
function isRuntimeCanonicalWorktreeIdForRepoHost(repo: Repo, worktreeId: string): boolean {
|
||||
const parsed = parseWorktreeKey(worktreeId)
|
||||
return parsed?.repoId === repo.id && parsed.hostId === getRepoExecutionHostId(repo)
|
||||
}
|
||||
|
||||
function isRuntimeWorktreeIdAliasForResolved(
|
||||
worktree: Pick<ResolvedWorktree, 'id' | 'repoId' | 'path' | 'hostId'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
if (worktree.id === worktreeId) {
|
||||
return true
|
||||
}
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
if (!parsed || parsed.repoId !== worktree.repoId) {
|
||||
return false
|
||||
}
|
||||
if (parsed.hostId !== undefined && worktree.hostId !== parsed.hostId) {
|
||||
return false
|
||||
}
|
||||
return runtimePathsEqual(parsed.worktreePath, worktree.path)
|
||||
}
|
||||
|
||||
function findResolvedWorktreeForIdAlias(
|
||||
worktrees: readonly ResolvedWorktree[],
|
||||
worktreeId: string
|
||||
): ResolvedWorktree | null {
|
||||
const matches = worktrees.filter((worktree) =>
|
||||
isRuntimeWorktreeIdAliasForResolved(worktree, worktreeId)
|
||||
)
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
|
||||
function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
@@ -1283,9 +1390,25 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt
|
||||
}
|
||||
|
||||
function listRuntimeFolderWorkspaces(
|
||||
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
|
||||
store: Pick<
|
||||
RuntimeStore,
|
||||
'getAllWorktreeMeta' | 'getWorktreeMeta' | 'setWorktreeMeta' | 'migrateWorktreeIdentity'
|
||||
>,
|
||||
repo: Repo
|
||||
): Worktree[] {
|
||||
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
|
||||
if (!isRuntimeLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
if (meta.hostId !== undefined && meta.hostId !== repoHostId) {
|
||||
continue
|
||||
}
|
||||
store.migrateWorktreeIdentity?.(
|
||||
worktreeId,
|
||||
getRuntimeCanonicalFolderWorkspaceId(repo, worktreeId)
|
||||
)
|
||||
}
|
||||
const rootId = getRuntimeFolderWorkspaceRootId(repo)
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const ids = Object.keys(allMeta).filter((worktreeId) =>
|
||||
@@ -7555,8 +7678,7 @@ export class OrcaRuntimeService {
|
||||
: null
|
||||
const cachedExplicitTargetWorktree =
|
||||
explicitTargetWorktreeId && cachedResolvedWorktrees
|
||||
? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ??
|
||||
null)
|
||||
? findResolvedWorktreeForIdAlias(cachedResolvedWorktrees, explicitTargetWorktreeId)
|
||||
: null
|
||||
const parsedExplicitTargetWorktree =
|
||||
explicitTargetWorktreeId && !cachedExplicitTargetWorktree
|
||||
@@ -7566,7 +7688,7 @@ export class OrcaRuntimeService {
|
||||
worktreeSelector && !explicitTargetWorktreeId
|
||||
? await this.resolveWorktreeSelector(worktreeSelector)
|
||||
: (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree)
|
||||
const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null
|
||||
const targetWorktreeId = targetWorktree?.id ?? explicitTargetWorktreeId ?? null
|
||||
const classificationResolvedWorktreeCache = this.resolvedWorktreeCache
|
||||
const classificationResolvedWorktrees =
|
||||
targetWorktreeId &&
|
||||
@@ -7614,9 +7736,13 @@ export class OrcaRuntimeService {
|
||||
|
||||
const terminals: RuntimeTerminalSummary[] = []
|
||||
const ptyIdsFromLeaves = new Set<string>()
|
||||
const matchesTargetWorktree = (worktreeId: string): boolean =>
|
||||
!targetWorktreeId ||
|
||||
worktreeId === targetWorktreeId ||
|
||||
(targetWorktree ? isRuntimeWorktreeIdAliasForResolved(targetWorktree, worktreeId) : false)
|
||||
if (graphEpoch !== null) {
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
|
||||
if (!matchesTargetWorktree(leaf.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
if (opts.requireFreshPtyLiveness && leaf.ptyId && !refreshedPtyLiveness?.has(leaf.ptyId)) {
|
||||
@@ -7642,7 +7768,7 @@ export class OrcaRuntimeService {
|
||||
if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) {
|
||||
continue
|
||||
}
|
||||
if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) {
|
||||
if (!matchesTargetWorktree(pty.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
|
||||
@@ -11308,9 +11434,12 @@ export class OrcaRuntimeService {
|
||||
this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees)
|
||||
}
|
||||
const detected = scan.worktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const meta = this.store
|
||||
? getRuntimeWorktreeMetaForRepoPath(this.store, repo, gitWorktree.path)
|
||||
: undefined
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
const detectedWorktree = this.toRuntimeDetectedWorktree(repo, worktree)
|
||||
if (scan.ok) {
|
||||
return detectedWorktree
|
||||
@@ -12386,7 +12515,7 @@ export class OrcaRuntimeService {
|
||||
throw new Error('Worktree created but not found in listing')
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const worktreeId = getRuntimeRepoWorktreeId(repo, created.path)
|
||||
const now = Date.now()
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
@@ -12453,7 +12582,9 @@ export class OrcaRuntimeService {
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
})
|
||||
const worktree = mergeWorktree(repo.id, created, meta)
|
||||
const worktree = mergeWorktree(repo.id, created, meta, undefined, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
const {
|
||||
lineage,
|
||||
workspaceLineage,
|
||||
@@ -13190,7 +13321,21 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
clearOptimisticReconcileToken(worktreeId: string): void {
|
||||
this.optimisticReconcileTokens.delete(worktreeId)
|
||||
for (const id of this.getKnownWorktreeIdAliases(worktreeId)) {
|
||||
this.optimisticReconcileTokens.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private getKnownWorktreeIdAliases(worktreeId: string): Set<string> {
|
||||
const ids = new Set([worktreeId])
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const repo = parsed ? this.store?.getRepo(parsed.repoId) : null
|
||||
if (!parsed || !repo) {
|
||||
return ids
|
||||
}
|
||||
ids.add(getRuntimeRepoWorktreeId(repo, parsed.worktreePath))
|
||||
ids.add(getRuntimeLegacyRepoWorktreeId(repo, parsed.worktreePath))
|
||||
return ids
|
||||
}
|
||||
|
||||
emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void {
|
||||
@@ -13930,8 +14075,14 @@ export class OrcaRuntimeService {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const removalTarget = parseExactWorktreeIdSelector(worktreeSelector)
|
||||
const repo = removalTarget ? this.store.getRepo(removalTarget.repoId) : undefined
|
||||
const canonicalRemovalTargetId =
|
||||
removalTarget && repo ? getRuntimeRepoWorktreeId(repo, removalTarget.path) : undefined
|
||||
const cleanupTarget = removalTarget
|
||||
? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id)
|
||||
? (this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) ??
|
||||
(canonicalRemovalTargetId
|
||||
? this.preservedBranchCleanupByWorktreeId.get(canonicalRemovalTargetId)
|
||||
: undefined))
|
||||
: undefined
|
||||
if (
|
||||
!removalTarget ||
|
||||
@@ -13942,7 +14093,6 @@ export class OrcaRuntimeService {
|
||||
throw new Error(`No preserved branch cleanup is pending for "${branchName}".`)
|
||||
}
|
||||
|
||||
const repo = this.store.getRepo(removalTarget.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
@@ -15433,12 +15583,12 @@ export class OrcaRuntimeService {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
const ptyIds = new Set<string>()
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.worktreeId === worktree.id && leaf.ptyId) {
|
||||
if (isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) && leaf.ptyId) {
|
||||
ptyIds.add(leaf.ptyId)
|
||||
}
|
||||
}
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (pty.worktreeId === worktree.id && pty.connected) {
|
||||
if (isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) && pty.connected) {
|
||||
ptyIds.add(pty.ptyId)
|
||||
}
|
||||
}
|
||||
@@ -15479,7 +15629,7 @@ export class OrcaRuntimeService {
|
||||
if (!refreshedPtyLiveness) {
|
||||
throw new Error('terminal_liveness_unavailable')
|
||||
}
|
||||
const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness)
|
||||
const livePtyIds = this.getLivePtyIdsForWorktree(worktree, refreshedPtyLiveness)
|
||||
const targetOnly = opts.targetOnly === true
|
||||
const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId))
|
||||
if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) {
|
||||
@@ -15511,7 +15661,7 @@ export class OrcaRuntimeService {
|
||||
postStopFailure: 'terminal_liveness_unavailable'
|
||||
}
|
||||
}
|
||||
const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness)
|
||||
const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree, postStopLiveness)
|
||||
const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId))
|
||||
if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) {
|
||||
return {
|
||||
@@ -15535,13 +15685,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private getLivePtyIdsForWorktree(
|
||||
worktreeId: string,
|
||||
worktree: ResolvedWorktree,
|
||||
freshPtyIds?: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
const ptyIds = new Set<string>()
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (
|
||||
leaf.worktreeId === worktreeId &&
|
||||
isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) &&
|
||||
leaf.connected &&
|
||||
leaf.ptyId &&
|
||||
(!freshPtyIds || freshPtyIds.has(leaf.ptyId))
|
||||
@@ -15551,7 +15701,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (
|
||||
pty.worktreeId === worktreeId &&
|
||||
isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) &&
|
||||
pty.connected &&
|
||||
(!freshPtyIds || freshPtyIds.has(pty.ptyId))
|
||||
) {
|
||||
@@ -15566,12 +15716,12 @@ export class OrcaRuntimeService {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.worktreeId === worktree.id && leaf.ptyId) {
|
||||
if (isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) && leaf.ptyId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (pty.worktreeId === worktree.id && pty.connected) {
|
||||
if (isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) && pty.connected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -15771,12 +15921,19 @@ export class OrcaRuntimeService {
|
||||
if (selector.startsWith('id:')) {
|
||||
const worktreeId = selector.slice(3)
|
||||
candidates = worktrees.filter((worktree) => worktree.id === worktreeId)
|
||||
if (candidates.length === 0) {
|
||||
candidates = worktrees.filter((worktree) =>
|
||||
isRuntimeWorktreeIdAliasForResolved(worktree, worktreeId)
|
||||
)
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const repo = parsed ? this.store?.getRepo(parsed.repoId) : null
|
||||
const fallback =
|
||||
repo?.connectionId && this.store?.getWorktreeMeta(worktreeId)
|
||||
? this.buildResolvedWorktreeFromId(worktreeId)
|
||||
repo?.connectionId && parsed
|
||||
? getRuntimeWorktreeMetaForRepoPath(this.requireStore(), repo, parsed.worktreePath)
|
||||
? this.buildResolvedWorktreeFromId(worktreeId)
|
||||
: null
|
||||
: null
|
||||
if (fallback !== null) {
|
||||
candidates = [fallback]
|
||||
@@ -15811,6 +15968,7 @@ export class OrcaRuntimeService {
|
||||
candidates = worktrees.filter(
|
||||
(worktree) =>
|
||||
worktree.id === selector ||
|
||||
isRuntimeWorktreeIdAliasForResolved(worktree, selector) ||
|
||||
runtimePathsEqual(worktree.path, selector) ||
|
||||
branchSelectorMatches(worktree.branch, selector)
|
||||
)
|
||||
@@ -16272,6 +16430,10 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId)
|
||||
const canonicalWorktreeId =
|
||||
repo && (parsed.hostId === undefined || parsed.hostId === getRepoExecutionHostId(repo))
|
||||
? getRuntimeRepoWorktreeId(repo, parsed.worktreePath)
|
||||
: worktreeId
|
||||
const git = {
|
||||
path: parsed.worktreePath,
|
||||
head: '',
|
||||
@@ -16279,11 +16441,20 @@ export class OrcaRuntimeService {
|
||||
isBare: false,
|
||||
isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false
|
||||
}
|
||||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||||
const merged = mergeWorktree(parsed.repoId, git, meta, repo?.displayName)
|
||||
const meta =
|
||||
repo && this.store
|
||||
? getRuntimeWorktreeMetaForRepoPath(this.store, repo, parsed.worktreePath)
|
||||
: this.store?.getWorktreeMeta(worktreeId)
|
||||
const merged = mergeWorktree(
|
||||
parsed.repoId,
|
||||
git,
|
||||
meta,
|
||||
repo?.displayName,
|
||||
repo ? { hostId: getRepoExecutionHostId(repo) } : undefined
|
||||
)
|
||||
return {
|
||||
...merged,
|
||||
id: worktreeId,
|
||||
id: canonicalWorktreeId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
@@ -16315,15 +16486,14 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
})
|
||||
)
|
||||
worktreeIds.add(targetWorktreeId)
|
||||
worktreeIds.add(targetWorktree.id)
|
||||
|
||||
const resolved: ResolvedWorktree[] = []
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const worktree =
|
||||
worktreeId === targetWorktreeId
|
||||
? targetWorktree
|
||||
: this.buildResolvedWorktreeFromId(worktreeId)
|
||||
if (worktree) {
|
||||
const worktree = isRuntimeWorktreeIdAliasForResolved(targetWorktree, worktreeId)
|
||||
? targetWorktree
|
||||
: this.buildResolvedWorktreeFromId(worktreeId)
|
||||
if (worktree && !resolved.some((entry) => entry.id === worktree.id)) {
|
||||
resolved.push(worktree)
|
||||
}
|
||||
}
|
||||
@@ -16358,10 +16528,10 @@ export class OrcaRuntimeService {
|
||||
if (!this.store) {
|
||||
return []
|
||||
}
|
||||
const store = this.store
|
||||
const now = Date.now()
|
||||
const metaById = this.store.getAllWorktreeMeta() ?? {}
|
||||
const perRepoWorktrees = await Promise.all(
|
||||
this.store.getRepos().map(async (repo) => {
|
||||
store.getRepos().map(async (repo) => {
|
||||
if (isFolderRepo(repo)) {
|
||||
return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({
|
||||
...worktree,
|
||||
@@ -16391,15 +16561,17 @@ export class OrcaRuntimeService {
|
||||
this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees)
|
||||
}
|
||||
return gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const worktreeId = getRuntimeRepoWorktreeId(repo, gitWorktree.path)
|
||||
// Why: lineage validation needs a durable instance ID even when the
|
||||
// runtime sees a workspace before the renderer's discovery-stamp path.
|
||||
const existingMeta = metaById[worktreeId]
|
||||
const existingMeta = getRuntimeWorktreeMetaForRepoPath(store, repo, gitWorktree.path)
|
||||
const meta =
|
||||
existingMeta && existingMeta.instanceId
|
||||
? existingMeta
|
||||
: this.store?.setWorktreeMeta(worktreeId, {})
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
: store.setWorktreeMeta(worktreeId, existingMeta ?? {})
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
return {
|
||||
...merged,
|
||||
parentWorktreeId: null,
|
||||
@@ -16438,8 +16610,10 @@ export class OrcaRuntimeService {
|
||||
const childIdsByParentId = new Map<string, string[]>()
|
||||
|
||||
for (const [childId, lineage] of Object.entries(lineageById)) {
|
||||
const child = worktreeById.get(childId)
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
const child = worktreeById.get(childId) ?? findResolvedWorktreeForIdAlias(worktrees, childId)
|
||||
const parent =
|
||||
worktreeById.get(lineage.parentWorktreeId) ??
|
||||
findResolvedWorktreeForIdAlias(worktrees, lineage.parentWorktreeId)
|
||||
if (
|
||||
!child ||
|
||||
!parent ||
|
||||
@@ -16450,10 +16624,15 @@ export class OrcaRuntimeService {
|
||||
// checkouts from appearing as children of stale same-path lineage.
|
||||
continue
|
||||
}
|
||||
validLineageByChildId.set(childId, lineage)
|
||||
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
|
||||
children.push(childId)
|
||||
childIdsByParentId.set(lineage.parentWorktreeId, children)
|
||||
const normalizedLineage = {
|
||||
...lineage,
|
||||
worktreeId: child.id,
|
||||
parentWorktreeId: parent.id
|
||||
}
|
||||
validLineageByChildId.set(child.id, normalizedLineage)
|
||||
const children = childIdsByParentId.get(parent.id) ?? []
|
||||
children.push(child.id)
|
||||
childIdsByParentId.set(parent.id, children)
|
||||
}
|
||||
|
||||
return worktrees.map((worktree) => {
|
||||
@@ -16476,14 +16655,19 @@ export class OrcaRuntimeService {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`))
|
||||
const liveIds = new Set(
|
||||
gitWorktrees.map((worktree) => getRuntimeRepoWorktreeId(repo, worktree.path))
|
||||
)
|
||||
const legacyLiveIds = new Set(
|
||||
gitWorktrees.map((worktree) => getRuntimeLegacyRepoWorktreeId(repo, worktree.path))
|
||||
)
|
||||
const repoPrefix = `${repo.id}::`
|
||||
for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) {
|
||||
const childScope = parseWorkspaceKey(childWorkspaceKey)
|
||||
if (
|
||||
childScope?.type === 'worktree' &&
|
||||
childScope.worktreeId.startsWith(repoPrefix) &&
|
||||
!liveIds.has(childScope.worktreeId)
|
||||
!legacyLiveIds.has(childScope.worktreeId)
|
||||
) {
|
||||
if (isWorkspaceKey(childWorkspaceKey)) {
|
||||
store.removeWorkspaceLineage?.(childWorkspaceKey)
|
||||
@@ -16491,7 +16675,10 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
|
||||
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
|
||||
if (
|
||||
(childId.startsWith(repoPrefix) && !legacyLiveIds.has(childId)) ||
|
||||
(isRuntimeCanonicalWorktreeIdForRepoHost(repo, childId) && !liveIds.has(childId))
|
||||
) {
|
||||
// Why: runtime selector scans can be the only scan before a path is
|
||||
// reused. Once a successful scan proves the child is gone, stale
|
||||
// lineage must not survive into the replacement checkout.
|
||||
@@ -16500,7 +16687,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
if (
|
||||
lineage.parentWorktreeId.startsWith(repoPrefix) &&
|
||||
!liveIds.has(lineage.parentWorktreeId)
|
||||
!legacyLiveIds.has(lineage.parentWorktreeId)
|
||||
) {
|
||||
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
|
||||
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
|
||||
@@ -16722,9 +16909,13 @@ export class OrcaRuntimeService {
|
||||
const sessions = sessionsResult.value
|
||||
const livePtyIds = new Set(sessions.map((session) => session.id))
|
||||
for (const session of sessions) {
|
||||
const worktreeId =
|
||||
const inferredWorktreeId =
|
||||
inferWorktreeIdFromPtyId(session.id) ??
|
||||
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
|
||||
const worktreeId = inferredWorktreeId
|
||||
? (findResolvedWorktreeForIdAlias(resolvedWorktrees, inferredWorktreeId)?.id ??
|
||||
inferredWorktreeId)
|
||||
: null
|
||||
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { listRegisteredPtys } from '../memory/pty-registry'
|
||||
import { makeLegacyWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
|
||||
|
||||
export type WorktreeTeardownDeps = {
|
||||
runtime?: OrcaRuntimeService
|
||||
@@ -51,13 +52,15 @@ export async function killAllProcessesForWorktree(
|
||||
result.runtimeStopped = r.stopped
|
||||
}
|
||||
|
||||
const worktreeIds = getTeardownWorktreeIds(worktreeId)
|
||||
|
||||
result.providerStopped = await sweepProviderByPrefix(
|
||||
worktreeId,
|
||||
worktreeIds,
|
||||
deps.localProvider,
|
||||
deps.onPtyStopped
|
||||
)
|
||||
result.registryStopped = await sweepRegistryForWorktree(
|
||||
worktreeId,
|
||||
worktreeIds,
|
||||
deps.localProvider,
|
||||
deps.onPtyStopped
|
||||
)
|
||||
@@ -66,15 +69,15 @@ export async function killAllProcessesForWorktree(
|
||||
}
|
||||
|
||||
async function sweepProviderByPrefix(
|
||||
worktreeId: string,
|
||||
worktreeIds: ReadonlySet<string>,
|
||||
provider: IPtyProvider,
|
||||
onPtyStopped?: (ptyId: string) => void
|
||||
): Promise<number> {
|
||||
const prefix = `${worktreeId}@@`
|
||||
const prefixes = [...worktreeIds].map((worktreeId) => `${worktreeId}@@`)
|
||||
const sessions = await provider.listProcesses().catch(() => [])
|
||||
let killed = 0
|
||||
for (const s of sessions) {
|
||||
if (!s.id.startsWith(prefix)) {
|
||||
if (!prefixes.some((prefix) => s.id.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
@@ -90,11 +93,13 @@ async function sweepProviderByPrefix(
|
||||
}
|
||||
|
||||
async function sweepRegistryForWorktree(
|
||||
worktreeId: string,
|
||||
worktreeIds: ReadonlySet<string>,
|
||||
localProvider: IPtyProvider,
|
||||
onPtyStopped?: (ptyId: string) => void
|
||||
): Promise<number> {
|
||||
const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId)
|
||||
const entries = listRegisteredPtys().filter(
|
||||
(r) => r.worktreeId !== null && worktreeIds.has(r.worktreeId)
|
||||
)
|
||||
let killed = 0
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
@@ -108,6 +113,15 @@ async function sweepRegistryForWorktree(
|
||||
return killed
|
||||
}
|
||||
|
||||
function getTeardownWorktreeIds(worktreeId: string): Set<string> {
|
||||
const ids = new Set([worktreeId])
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (parsed) {
|
||||
ids.add(makeLegacyWorktreeId(parsed.repoId, parsed.worktreePath))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void {
|
||||
if (!onPtyStopped) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { basename } from 'path'
|
||||
import type { Repo } from '../shared/types'
|
||||
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id'
|
||||
import {
|
||||
makeRepoWorktreeKey,
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem
|
||||
} from '../shared/worktree-id'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import type { Store } from './persistence'
|
||||
|
||||
@@ -26,7 +30,7 @@ export function loadKnownUsageWorktreesByRepo(
|
||||
for (const repo of localRepos) {
|
||||
worktreesByRepo.set(repo.id, [
|
||||
{
|
||||
worktreeId: `${repo.id}::${repo.path}`,
|
||||
worktreeId: makeRepoWorktreeKey(repo, repo.path),
|
||||
path: repo.path,
|
||||
displayName: repo.displayName || getDefaultUsageWorktreeLabel(repo.path)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
|
||||
import { mergeWorktree } from './ipc/worktree-logic'
|
||||
import { getRepoExecutionHostId } from '../shared/execution-host'
|
||||
import { makeRepoWorktreeKey } from '../shared/worktree-id'
|
||||
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
const LOCAL_FS_CONCURRENCY = 48
|
||||
@@ -797,8 +799,10 @@ async function listWorktreesForSpaceScan(
|
||||
}
|
||||
|
||||
function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName)
|
||||
const worktreeId = makeRepoWorktreeKey(repo, gitWorktree.path)
|
||||
return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName, {
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
}
|
||||
|
||||
function reportProgress(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-re
|
||||
import { toast } from 'sonner'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
import { getRepoIdFromWorktreeId, makeRepoWorktreeKey } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
canInspectLocalMcpConfigRoot,
|
||||
inspectMcpConfigContent,
|
||||
@@ -63,9 +63,26 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
||||
return (
|
||||
worktreesForRepo.find((worktree) => worktree.isMainWorktree) ??
|
||||
worktreesForRepo.find((worktree) => worktree.path === repo.path) ??
|
||||
worktreesForRepo[0] ?? { id: `${repo.id}::${repo.path}`, path: repo.path }
|
||||
worktreesForRepo[0] ?? {
|
||||
id: makeRepoWorktreeKey(
|
||||
{
|
||||
id: repo.id,
|
||||
connectionId: repo.connectionId,
|
||||
executionHostId: repo.executionHostId
|
||||
},
|
||||
repo.path
|
||||
),
|
||||
path: repo.path
|
||||
}
|
||||
)
|
||||
}, [activeWorktreeId, repo.id, repo.path, worktreesForRepo])
|
||||
}, [
|
||||
activeWorktreeId,
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
repo.id,
|
||||
repo.path,
|
||||
worktreesForRepo
|
||||
])
|
||||
const targetWorktreeId = targetWorktree.id
|
||||
const targetRootPath = targetWorktree.path
|
||||
const detectedCount = useMemo(() => configs.filter((config) => config.exists).length, [configs])
|
||||
|
||||
@@ -83,7 +83,7 @@ const mockApi = {
|
||||
// @ts-expect-error -- test shim
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createWorktreeSlice } from './worktrees'
|
||||
import { createWorktreeSlice, WORKTREE_ID_KEYED_MAP_KEYS } from './worktrees'
|
||||
import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation'
|
||||
import { getHostedReviewCacheKey } from './hosted-review'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from '../../components/browser-pane/webview-registry'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { makeWorktreeKey } from '../../../../shared/worktree-id'
|
||||
|
||||
function resetRemoteRuntimeMocks() {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
@@ -5001,6 +5002,34 @@ describe('setWorktreesPinnedAndReveal', () => {
|
||||
describe('migrateWorktreeIdentity', () => {
|
||||
const OLD = 'repo1::/ws/cunner'
|
||||
const NEW = 'repo1::/ws/worktree-creation-spinner'
|
||||
const CANONICAL = makeWorktreeKey({ hostId: 'local', repoId: 'repo1', path: '/ws/cunner' })
|
||||
|
||||
function makeMigrationMapValue(
|
||||
key: (typeof WORKTREE_ID_KEYED_MAP_KEYS)[number],
|
||||
worktreeId: string
|
||||
): unknown {
|
||||
switch (key) {
|
||||
case 'worktreeLineageById':
|
||||
return makeLineage({ worktreeId, parentWorktreeId: 'repo1::/ws/parent' })
|
||||
case 'tabsByWorktree':
|
||||
return [{ id: 'tab1', worktreeId }]
|
||||
case 'browserTabsByWorktree':
|
||||
return [{ id: 'browser1', worktreeId }]
|
||||
case 'recentlyClosedBrowserTabsByWorktree':
|
||||
return [
|
||||
{
|
||||
workspace: { id: 'closed-browser', worktreeId },
|
||||
pages: [{ id: 'closed-page', worktreeId }]
|
||||
}
|
||||
]
|
||||
case 'unifiedTabsByWorktree':
|
||||
return [{ id: 'unified1', worktreeId }]
|
||||
case 'groupsByWorktree':
|
||||
return [{ id: 'group1', worktreeId }]
|
||||
default:
|
||||
return { marker: key }
|
||||
}
|
||||
}
|
||||
|
||||
it('re-keys worktree-scoped maps, pointers, the Set, and openFiles old->new', () => {
|
||||
const store = createTestStore()
|
||||
@@ -5083,6 +5112,50 @@ describe('migrateWorktreeIdentity', () => {
|
||||
expect(s.terminalLayoutsByTabId.tab1).toBeDefined()
|
||||
})
|
||||
|
||||
it('moves every declared worktree-id keyed map and lineage relationship to the canonical id', () => {
|
||||
const store = createTestStore()
|
||||
const childId = 'repo1::/ws/child'
|
||||
const mapState = Object.fromEntries(
|
||||
WORKTREE_ID_KEYED_MAP_KEYS.map((key) => [key, { [OLD]: makeMigrationMapValue(key, OLD) }])
|
||||
) as Partial<AppState>
|
||||
|
||||
store.setState({
|
||||
...mapState,
|
||||
worktreeLineageById: {
|
||||
[OLD]: makeLineage({ worktreeId: OLD, parentWorktreeId: 'repo1::/ws/parent' }),
|
||||
[childId]: makeLineage({ worktreeId: childId, parentWorktreeId: OLD })
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
[worktreeWorkspaceKey(OLD)]: makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(OLD),
|
||||
parentWorkspaceKey: folderWorkspaceKey('folder-parent')
|
||||
}),
|
||||
[worktreeWorkspaceKey(childId)]: makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(childId),
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(OLD)
|
||||
})
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
store.getState().migrateWorktreeIdentity(OLD, CANONICAL)
|
||||
const s = store.getState()
|
||||
|
||||
for (const key of WORKTREE_ID_KEYED_MAP_KEYS) {
|
||||
const map = s[key] as Record<string, unknown>
|
||||
expect(map[OLD], `${key} retained the legacy key`).toBeUndefined()
|
||||
expect(map[CANONICAL], `${key} did not receive the canonical key`).toBeDefined()
|
||||
}
|
||||
expect(s.worktreeLineageById[CANONICAL]).toMatchObject({ worktreeId: CANONICAL })
|
||||
expect(s.worktreeLineageById[childId]).toMatchObject({ parentWorktreeId: CANONICAL })
|
||||
expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(OLD)]).toBeUndefined()
|
||||
expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(CANONICAL)]).toMatchObject({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(CANONICAL)
|
||||
})
|
||||
expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toMatchObject({
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(CANONICAL)
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when the ids match', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
RemoveWorktreeResult,
|
||||
WorktreeLineage,
|
||||
WorkspaceLineage,
|
||||
WorktreeMeta
|
||||
WorkspaceKey,
|
||||
WorktreeMeta,
|
||||
Repo
|
||||
} from '../../../../shared/types'
|
||||
import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types'
|
||||
import {
|
||||
@@ -1247,7 +1249,7 @@ function encodePushTargetClearForRuntimeRpc(
|
||||
// new `*ByWorktree` map is not silently missed when a worktree id changes. Maps keyed
|
||||
// by tab id or file id are deliberately NOT here — tabs and files keep their ids across
|
||||
// a worktree rename.
|
||||
const WORKTREE_ID_KEYED_MAP_KEYS = [
|
||||
export const WORKTREE_ID_KEYED_MAP_KEYS = [
|
||||
'worktreeLineageById',
|
||||
'tabsByWorktree',
|
||||
'deleteStateByWorktreeId',
|
||||
@@ -1283,6 +1285,36 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
|
||||
'defaultTerminalTabsAppliedByWorktreeId'
|
||||
] as const satisfies readonly (keyof AppState)[]
|
||||
|
||||
function migrateWorkspaceLineageForWorktreeIdentity(
|
||||
lineageByChildKey: Record<string, WorkspaceLineage>,
|
||||
oldWorkspaceKey: WorkspaceKey,
|
||||
newWorkspaceKey: WorkspaceKey
|
||||
): Record<string, WorkspaceLineage> {
|
||||
let changed = false
|
||||
const next: Record<string, WorkspaceLineage> = {}
|
||||
for (const [childKey, lineage] of Object.entries(lineageByChildKey)) {
|
||||
const nextChildKey = childKey === oldWorkspaceKey ? newWorkspaceKey : childKey
|
||||
const nextLineage =
|
||||
lineage.childWorkspaceKey === oldWorkspaceKey ||
|
||||
lineage.parentWorkspaceKey === oldWorkspaceKey
|
||||
? {
|
||||
...lineage,
|
||||
childWorkspaceKey:
|
||||
lineage.childWorkspaceKey === oldWorkspaceKey
|
||||
? newWorkspaceKey
|
||||
: lineage.childWorkspaceKey,
|
||||
parentWorkspaceKey:
|
||||
lineage.parentWorkspaceKey === oldWorkspaceKey
|
||||
? newWorkspaceKey
|
||||
: lineage.parentWorkspaceKey
|
||||
}
|
||||
: lineage
|
||||
changed = changed || nextChildKey !== childKey || nextLineage !== lineage
|
||||
next[nextChildKey] = nextLineage
|
||||
}
|
||||
return changed ? next : lineageByChildKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key every worktree-id-keyed map (plus the Set, openFiles[].worktreeId, and
|
||||
* the active/renaming pointers) from `oldWorktreeId` to `newWorktreeId` after a
|
||||
@@ -1301,6 +1333,8 @@ function buildWorktreeRenameState(
|
||||
if (oldWorktreeId === newWorktreeId) {
|
||||
return {}
|
||||
}
|
||||
const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId)
|
||||
const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId)
|
||||
const renamed: Record<string, unknown> = {}
|
||||
const renameKey = <T>(
|
||||
key: keyof AppState,
|
||||
@@ -1317,7 +1351,14 @@ function buildWorktreeRenameState(
|
||||
}
|
||||
const withNewWorktreeId = <T extends { worktreeId: string }>(value: T): T =>
|
||||
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
|
||||
const withNewLineageWorktreeIds = (lineage: WorktreeLineage): WorktreeLineage => ({
|
||||
...lineage,
|
||||
worktreeId: lineage.worktreeId === oldWorktreeId ? newWorktreeId : lineage.worktreeId,
|
||||
parentWorktreeId:
|
||||
lineage.parentWorktreeId === oldWorktreeId ? newWorktreeId : lineage.parentWorktreeId
|
||||
})
|
||||
const renameValueByKey: Partial<Record<(typeof WORKTREE_ID_KEYED_MAP_KEYS)[number], unknown>> = {
|
||||
worktreeLineageById: withNewLineageWorktreeIds,
|
||||
tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId),
|
||||
browserTabsByWorktree: (workspaces: { worktreeId: string }[]) =>
|
||||
workspaces.map(withNewWorktreeId),
|
||||
@@ -1335,6 +1376,28 @@ function buildWorktreeRenameState(
|
||||
for (const key of WORKTREE_ID_KEYED_MAP_KEYS) {
|
||||
renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined)
|
||||
}
|
||||
const currentLineage =
|
||||
(renamed.worktreeLineageById as Record<string, WorktreeLineage> | undefined) ??
|
||||
s.worktreeLineageById
|
||||
const lineageWithUpdatedParents = Object.values(currentLineage).some(
|
||||
(lineage) => lineage.worktreeId === oldWorktreeId || lineage.parentWorktreeId === oldWorktreeId
|
||||
)
|
||||
? Object.fromEntries(
|
||||
Object.entries(currentLineage).map(([worktreeId, lineage]) => [
|
||||
worktreeId,
|
||||
withNewLineageWorktreeIds(lineage)
|
||||
])
|
||||
)
|
||||
: currentLineage
|
||||
if (lineageWithUpdatedParents !== s.worktreeLineageById) {
|
||||
renamed.worktreeLineageById = lineageWithUpdatedParents
|
||||
}
|
||||
const currentWorkspaceLineage = s.workspaceLineageByChildKey ?? {}
|
||||
const workspaceLineageByChildKey = migrateWorkspaceLineageForWorktreeIdentity(
|
||||
currentWorkspaceLineage,
|
||||
oldWorkspaceKey,
|
||||
newWorkspaceKey
|
||||
)
|
||||
// Re-key these on rename so a renamed worktree keeps its editor-undo + push/pull
|
||||
// state. (Both removal paths — buildWorktreePurgeState and the single
|
||||
// removeWorktree reducer — now also purge them on removal.)
|
||||
@@ -1407,6 +1470,9 @@ function buildWorktreeRenameState(
|
||||
...(sleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey
|
||||
? { sleepingAgentSessionsByPaneKey }
|
||||
: {}),
|
||||
...(workspaceLineageByChildKey !== currentWorkspaceLineage
|
||||
? { workspaceLineageByChildKey }
|
||||
: {}),
|
||||
...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}),
|
||||
// The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed.
|
||||
...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId)
|
||||
|
||||
@@ -11,8 +11,31 @@
|
||||
* can import.
|
||||
*/
|
||||
|
||||
import { normalizeExecutionHostId } from './execution-host'
|
||||
|
||||
export const PTY_SESSION_ID_SEPARATOR = '@@'
|
||||
export const WORKTREE_ID_SEPARATOR = '::'
|
||||
const WORKTREE_KEY_PREFIX = 'orca-worktree://v1?'
|
||||
|
||||
function isCanonicalWorktreeKey(candidate: string): boolean {
|
||||
if (!candidate.startsWith(WORKTREE_KEY_PREFIX)) {
|
||||
return false
|
||||
}
|
||||
const params = new URLSearchParams(candidate.slice(WORKTREE_KEY_PREFIX.length))
|
||||
const keys = [...params.keys()]
|
||||
return (
|
||||
keys.length === 3 &&
|
||||
keys[0] === 'hostId' &&
|
||||
keys[1] === 'repoId' &&
|
||||
keys[2] === 'path' &&
|
||||
params.getAll('hostId').length === 1 &&
|
||||
params.getAll('repoId').length === 1 &&
|
||||
params.getAll('path').length === 1 &&
|
||||
normalizeExecutionHostId(params.get('hostId')) !== null &&
|
||||
(params.get('repoId')?.length ?? 0) > 0 &&
|
||||
(params.get('path')?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the owning worktreeId from a minted session id.
|
||||
@@ -30,6 +53,9 @@ export function parsePtySessionId(sessionId: string): { worktreeId: string | nul
|
||||
return { worktreeId: null }
|
||||
}
|
||||
const candidate = sessionId.slice(0, idx)
|
||||
if (isCanonicalWorktreeKey(candidate)) {
|
||||
return { worktreeId: candidate }
|
||||
}
|
||||
// Why: require non-empty halves on both sides of `::` so degenerate
|
||||
// ids like `::@@…`, `repo::@@…`, or `::path@@…` don't synthesize a
|
||||
// phantom worktreeId for memory attribution.
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
WORKTREE_ID_SEPARATOR,
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId,
|
||||
isLegacyWorktreeId,
|
||||
makeRepoWorktreeKey,
|
||||
makeWorktreeKey,
|
||||
parseAnyWorktreeId,
|
||||
parseWorktreeKey,
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem
|
||||
} from './worktree-id'
|
||||
@@ -18,6 +23,14 @@ describe('getRepoIdFromWorktreeId', () => {
|
||||
expect(getRepoIdFromWorktreeId('repo-123::/abs/path')).toBe('repo-123')
|
||||
})
|
||||
|
||||
it('returns the repo id for a host-qualified worktree key', () => {
|
||||
expect(
|
||||
getRepoIdFromWorktreeId(
|
||||
makeWorktreeKey({ hostId: 'runtime:gpu', repoId: 'repo-123', path: '/abs/path' })
|
||||
)
|
||||
).toBe('repo-123')
|
||||
})
|
||||
|
||||
it('returns the whole input when there is no separator', () => {
|
||||
expect(getRepoIdFromWorktreeId('just-a-repo-id')).toBe('just-a-repo-id')
|
||||
})
|
||||
@@ -43,6 +56,96 @@ describe('getRepoIdFromWorktreeId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeWorktreeKey', () => {
|
||||
it('creates a canonical host-qualified key with stable parameter order', () => {
|
||||
expect(
|
||||
makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/Users/alice/orca' })
|
||||
).toBe('orca-worktree://v1?hostId=local&repoId=repo-123&path=%2FUsers%2Falice%2Forca')
|
||||
})
|
||||
|
||||
it('encodes host ids, repo ids, POSIX paths, and Windows paths without delimiter collisions', () => {
|
||||
const key = makeWorktreeKey({
|
||||
hostId: 'ssh:openclaw%202',
|
||||
repoId: 'repo::with spaces',
|
||||
path: 'C:\\Users\\Alice\\repo::workspace'
|
||||
})
|
||||
|
||||
expect(parseWorktreeKey(key)).toEqual({
|
||||
hostId: 'ssh:openclaw%202',
|
||||
repoId: 'repo::with spaces',
|
||||
worktreePath: 'C:\\Users\\Alice\\repo::workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds keys from repo execution ownership', () => {
|
||||
expect(
|
||||
makeRepoWorktreeKey(
|
||||
{ id: 'repo-123', connectionId: null, executionHostId: 'runtime:gpu' },
|
||||
'/srv/repo'
|
||||
)
|
||||
).toBe('orca-worktree://v1?hostId=runtime%3Agpu&repoId=repo-123&path=%2Fsrv%2Frepo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorktreeKey', () => {
|
||||
it('parses a valid canonical key', () => {
|
||||
expect(
|
||||
parseWorktreeKey('orca-worktree://v1?hostId=runtime%3Agpu&repoId=repo-123&path=%2Fsrv%2Frepo')
|
||||
).toEqual({ hostId: 'runtime:gpu', repoId: 'repo-123', worktreePath: '/srv/repo' })
|
||||
})
|
||||
|
||||
it('rejects legacy ids', () => {
|
||||
expect(parseWorktreeKey('repo-123::/abs/path')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects missing, empty, duplicate, reordered, and unknown fields', () => {
|
||||
expect(parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo')).toBeNull()
|
||||
expect(parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=')).toBeNull()
|
||||
expect(
|
||||
parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=%2Fx&path=%2Fy')
|
||||
).toBeNull()
|
||||
expect(parseWorktreeKey('orca-worktree://v1?repoId=repo&hostId=local&path=%2Fx')).toBeNull()
|
||||
expect(
|
||||
parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=%2Fx&extra=1')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unknown versions and invalid hosts', () => {
|
||||
expect(parseWorktreeKey('orca-worktree://v2?hostId=local&repoId=repo&path=%2Fx')).toBeNull()
|
||||
expect(parseWorktreeKey('orca-worktree://v1?hostId=bogus&repoId=repo&path=%2Fx')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnyWorktreeId', () => {
|
||||
it('distinguishes canonical and legacy ids', () => {
|
||||
const canonical = makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' })
|
||||
|
||||
expect(parseAnyWorktreeId(canonical)).toEqual({
|
||||
format: 'canonical',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-123',
|
||||
worktreePath: '/abs/path'
|
||||
})
|
||||
expect(parseAnyWorktreeId('repo-123::/abs/path')).toEqual({
|
||||
format: 'legacy',
|
||||
repoId: 'repo-123',
|
||||
worktreePath: '/abs/path'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyWorktreeId', () => {
|
||||
it('returns true only for legacy ids', () => {
|
||||
expect(isLegacyWorktreeId('repo-123::/abs/path')).toBe(true)
|
||||
expect(
|
||||
isLegacyWorktreeId(
|
||||
makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isLegacyWorktreeId('repo-123')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitWorktreeId', () => {
|
||||
it('splits a canonical worktree id into repo id and path', () => {
|
||||
expect(splitWorktreeId('repo-123::/abs/path')).toEqual({
|
||||
@@ -51,6 +154,12 @@ describe('splitWorktreeId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('splits a host-qualified worktree key into host, repo id, and path', () => {
|
||||
expect(
|
||||
splitWorktreeId(makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' }))
|
||||
).toEqual({ hostId: 'local', repoId: 'repo-123', worktreePath: '/abs/path' })
|
||||
})
|
||||
|
||||
it('returns null when there is no separator', () => {
|
||||
expect(splitWorktreeId('just-a-repo-id')).toBeNull()
|
||||
})
|
||||
@@ -91,6 +200,18 @@ describe('splitWorktreeIdForFilesystem', () => {
|
||||
splitWorktreeIdForFilesystem('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
|
||||
).toEqual({ repoId: 'repo', worktreePath: '/folder' })
|
||||
})
|
||||
|
||||
it('strips folder workspace instance suffixes from host-qualified keys', () => {
|
||||
expect(
|
||||
splitWorktreeIdForFilesystem(
|
||||
makeWorktreeKey({
|
||||
hostId: 'local',
|
||||
repoId: 'repo',
|
||||
path: '/folder::workspace:123e4567-e89b-12d3-a456-426614174000'
|
||||
})
|
||||
)
|
||||
).toEqual({ hostId: 'local', repoId: 'repo', worktreePath: '/folder' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreePathBasenameFromId', () => {
|
||||
|
||||
+123
-1
@@ -1,23 +1,136 @@
|
||||
import { WORKTREE_ID_SEPARATOR } from './pty-session-id-format'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import type { Repo } from './types'
|
||||
|
||||
export { WORKTREE_ID_SEPARATOR } from './pty-session-id-format'
|
||||
|
||||
export type ParsedWorktreeId = {
|
||||
repoId: string
|
||||
worktreePath: string
|
||||
hostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type ParsedCanonicalWorktreeKey = {
|
||||
hostId: ExecutionHostId
|
||||
repoId: string
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
export type ParsedAnyWorktreeId =
|
||||
| ({ format: 'canonical' } & ParsedCanonicalWorktreeKey)
|
||||
| ({ format: 'legacy' } & ParsedWorktreeId)
|
||||
|
||||
export const FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:'
|
||||
const WORKTREE_KEY_PREFIX = 'orca-worktree://v1?'
|
||||
const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp(
|
||||
`${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$`
|
||||
)
|
||||
|
||||
function encodeKeyPart(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function readSingleParam(params: URLSearchParams, key: string): string | null {
|
||||
const values = params.getAll(key)
|
||||
if (values.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const [value] = values
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function makeLegacyWorktreeId(repoId: string, path: string): string {
|
||||
return `${repoId}${WORKTREE_ID_SEPARATOR}${path}`
|
||||
}
|
||||
|
||||
export function makeWorktreeKey(input: {
|
||||
hostId: ExecutionHostId
|
||||
repoId: string
|
||||
path: string
|
||||
}): string {
|
||||
return `${WORKTREE_KEY_PREFIX}${[
|
||||
`hostId=${encodeKeyPart(input.hostId)}`,
|
||||
`repoId=${encodeKeyPart(input.repoId)}`,
|
||||
`path=${encodeKeyPart(input.path)}`
|
||||
].join('&')}`
|
||||
}
|
||||
|
||||
export function makeRepoWorktreeKey(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
path: string
|
||||
): string {
|
||||
return makeWorktreeKey({
|
||||
hostId: getRepoExecutionHostId(repo),
|
||||
repoId: repo.id,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
export function getRepoWorktreeIdAliases(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
path: string
|
||||
): Set<string> {
|
||||
return new Set([makeRepoWorktreeKey(repo, path), makeLegacyWorktreeId(repo.id, path)])
|
||||
}
|
||||
|
||||
export function isRepoWorktreeIdAlias(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
path: string,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
return getRepoWorktreeIdAliases(repo, path).has(worktreeId)
|
||||
}
|
||||
|
||||
export function parseWorktreeKey(worktreeId: string): ParsedCanonicalWorktreeKey | null {
|
||||
if (!worktreeId.startsWith(WORKTREE_KEY_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const query = worktreeId.slice(WORKTREE_KEY_PREFIX.length)
|
||||
const params = new URLSearchParams(query)
|
||||
const keys = [...params.keys()]
|
||||
if (keys.length !== 3 || keys[0] !== 'hostId' || keys[1] !== 'repoId' || keys[2] !== 'path') {
|
||||
return null
|
||||
}
|
||||
const rawHostId = readSingleParam(params, 'hostId')
|
||||
const rawRepoId = readSingleParam(params, 'repoId')
|
||||
const rawPath = readSingleParam(params, 'path')
|
||||
if (!rawHostId || !rawRepoId || !rawPath) {
|
||||
return null
|
||||
}
|
||||
const hostId = normalizeExecutionHostId(rawHostId)
|
||||
if (!hostId) {
|
||||
return null
|
||||
}
|
||||
return { hostId, repoId: rawRepoId, worktreePath: rawPath }
|
||||
}
|
||||
|
||||
export function parseAnyWorktreeId(worktreeId: string): ParsedAnyWorktreeId | null {
|
||||
const canonical = parseWorktreeKey(worktreeId)
|
||||
if (canonical) {
|
||||
return { format: 'canonical', ...canonical }
|
||||
}
|
||||
const legacy = splitLegacyWorktreeId(worktreeId)
|
||||
return legacy ? { format: 'legacy', ...legacy } : null
|
||||
}
|
||||
|
||||
export function isLegacyWorktreeId(worktreeId: string): boolean {
|
||||
return !parseWorktreeKey(worktreeId) && splitLegacyWorktreeId(worktreeId) !== null
|
||||
}
|
||||
|
||||
export function getRepoIdFromWorktreeId(worktreeId: string): string {
|
||||
const canonical = parseWorktreeKey(worktreeId)
|
||||
if (canonical) {
|
||||
return canonical.repoId
|
||||
}
|
||||
const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR)
|
||||
return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
|
||||
}
|
||||
|
||||
export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null {
|
||||
function splitLegacyWorktreeId(worktreeId: string): ParsedWorktreeId | null {
|
||||
const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR)
|
||||
if (separatorIdx === -1) {
|
||||
return null
|
||||
@@ -28,12 +141,21 @@ export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null {
|
||||
const canonical = parseWorktreeKey(worktreeId)
|
||||
if (canonical) {
|
||||
return canonical
|
||||
}
|
||||
return splitLegacyWorktreeId(worktreeId)
|
||||
}
|
||||
|
||||
export function splitWorktreeIdForFilesystem(worktreeId: string): ParsedWorktreeId | null {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...(parsed.hostId !== undefined ? { hostId: parsed.hostId } : {}),
|
||||
repoId: parsed.repoId,
|
||||
// Why: folder projects can have multiple workspace sessions backed by the
|
||||
// same directory. Their IDs carry a UUID suffix, but filesystem callers
|
||||
|
||||
Reference in New Issue
Block a user