Add host-qualified worktree identities

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-23 23:49:29 -07:00
co-authored by Orca
parent 2e9518e82b
commit 25801d2ef5
22 changed files with 1287 additions and 209 deletions
@@ -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.
+19
View File
@@ -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 })
})
+13 -3
View File
@@ -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
}
}
+3 -2
View File
@@ -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}`
})
+40 -9
View File
@@ -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]
+12 -3
View File
@@ -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 }
: {}),
+23 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 () => {
+11 -11
View File
@@ -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:
+133 -41
View File
@@ -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)
})
})
+244 -53
View File
@@ -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
}
+21 -7
View File
@@ -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
+6 -2
View File
@@ -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)
}
+6 -2
View File
@@ -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({
+68 -2
View File
@@ -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)
+26
View File
@@ -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.
+121
View File
@@ -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
View File
@@ -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